encode-toolkit 0.3.0__py3-none-any.whl

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.
@@ -0,0 +1,1544 @@
1
+ """ENCODE Project MCP Server.
2
+
3
+ Exposes ENCODE REST API as Claude-compatible tools for searching experiments,
4
+ listing files, and downloading genomics data.
5
+
6
+ All data stays local. Only connects to encodeproject.org over HTTPS.
7
+ No telemetry, no analytics, no data sent elsewhere.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ from contextlib import asynccontextmanager
16
+ from typing import Any, Literal
17
+
18
+ from mcp.server.fastmcp import FastMCP
19
+ from mcp.types import ToolAnnotations
20
+
21
+ from encode_connector.client.auth import CredentialManager
22
+ from encode_connector.client.constants import (
23
+ ASSAY_TITLES,
24
+ BIOSAMPLE_CLASSIFICATIONS,
25
+ ORGAN_SLIMS,
26
+ )
27
+ from encode_connector.client.downloader import FileDownloader
28
+ from encode_connector.client.encode_client import EncodeClient
29
+ from encode_connector.client.models import _human_size
30
+ from encode_connector.client.tracker import (
31
+ ExperimentTracker,
32
+ parse_encode_pipelines,
33
+ parse_encode_publications,
34
+ )
35
+ from encode_connector.client.validation import (
36
+ check_filter_value,
37
+ clamp_limit,
38
+ validate_accession,
39
+ validate_data_export_format,
40
+ validate_encode_path,
41
+ validate_export_format,
42
+ validate_organize_by,
43
+ validate_reference_type,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # --- Safety annotations for MCP tools ---
49
+ # Read-only tools that query the ENCODE API (network access, no local writes)
50
+ _READONLY_API = ToolAnnotations(
51
+ readOnlyHint=True,
52
+ destructiveHint=False,
53
+ idempotentHint=True,
54
+ openWorldHint=True,
55
+ )
56
+ # Read-only tools that read from local tracker DB only (no network)
57
+ _READONLY_LOCAL = ToolAnnotations(
58
+ readOnlyHint=True,
59
+ destructiveHint=False,
60
+ idempotentHint=True,
61
+ openWorldHint=False,
62
+ )
63
+ # Tools that write to local tracker DB (no network)
64
+ _WRITE_LOCAL = ToolAnnotations(
65
+ readOnlyHint=False,
66
+ destructiveHint=False,
67
+ idempotentHint=True,
68
+ openWorldHint=False,
69
+ )
70
+ # Tools that write to local tracker DB AND query ENCODE API (network)
71
+ _WRITE_LOCAL_API = ToolAnnotations(
72
+ readOnlyHint=False,
73
+ destructiveHint=False,
74
+ idempotentHint=True,
75
+ openWorldHint=True,
76
+ )
77
+ # Tools that download files to disk (network + disk writes)
78
+ _DOWNLOAD = ToolAnnotations(
79
+ readOnlyHint=False,
80
+ destructiveHint=False,
81
+ idempotentHint=True,
82
+ openWorldHint=True,
83
+ )
84
+ # Credential management (can clear credentials = destructive)
85
+ _CREDENTIAL_MGMT = ToolAnnotations(
86
+ readOnlyHint=False,
87
+ destructiveHint=True,
88
+ idempotentHint=True,
89
+ openWorldHint=False,
90
+ )
91
+
92
+
93
+ def _validate_filters(
94
+ assay_title: str | None = None,
95
+ organ: str | None = None,
96
+ biosample_type: str | None = None,
97
+ ) -> list[str]:
98
+ """Validate common filter parameters against known ENCODE values.
99
+
100
+ Returns a list of warning strings (empty if all values are valid).
101
+ """
102
+ warnings: list[str] = []
103
+ if assay_title:
104
+ warning = check_filter_value(assay_title, ASSAY_TITLES, "assay_title")
105
+ if warning:
106
+ warnings.append(warning)
107
+ if organ:
108
+ warning = check_filter_value(organ, ORGAN_SLIMS, "organ")
109
+ if warning:
110
+ warnings.append(warning)
111
+ if biosample_type:
112
+ warning = check_filter_value(biosample_type, BIOSAMPLE_CLASSIFICATIONS, "biosample_type")
113
+ if warning:
114
+ warnings.append(warning)
115
+ return warnings
116
+
117
+
118
+ # Global client instances (managed via lifespan)
119
+ _client: EncodeClient | None = None
120
+ _downloader: FileDownloader | None = None
121
+ _credential_manager = CredentialManager()
122
+ _tracker: ExperimentTracker | None = None
123
+ _client_lock: asyncio.Lock | None = None
124
+
125
+
126
+ def _get_client_lock() -> asyncio.Lock:
127
+ """Get or create the client lock in the current event loop."""
128
+ global _client_lock
129
+ if _client_lock is None:
130
+ _client_lock = asyncio.Lock()
131
+ return _client_lock
132
+
133
+
134
+ @asynccontextmanager
135
+ async def lifespan(server: FastMCP):
136
+ """Manage client lifecycle."""
137
+ global _client, _downloader, _tracker, _client_lock
138
+ # Initialize lock eagerly in lifespan so it's always bound to the
139
+ # correct event loop, rather than lazy-creating in _get_client_lock()
140
+ _client_lock = asyncio.Lock()
141
+ _client = EncodeClient(credential_manager=_credential_manager)
142
+ _downloader = FileDownloader(credential_manager=_credential_manager)
143
+ _tracker = ExperimentTracker()
144
+ try:
145
+ yield
146
+ finally:
147
+ if _client:
148
+ await _client.close()
149
+ if _tracker:
150
+ _tracker.close()
151
+
152
+
153
+ mcp = FastMCP(
154
+ "ENCODE Project",
155
+ instructions=(
156
+ "Query and download genomics data from the ENCODE Project (encodeproject.org). "
157
+ "Search experiments by assay type, organism, organ, biosample, target, and more. "
158
+ "List and download files (FASTQ, BAM, BED, bigWig, etc.). "
159
+ "All data stays local - no telemetry or external data sharing."
160
+ ),
161
+ lifespan=lifespan,
162
+ )
163
+
164
+
165
+ async def _get_client() -> EncodeClient:
166
+ async with _get_client_lock():
167
+ if _client is None:
168
+ raise RuntimeError("ENCODE client not initialized")
169
+ return _client
170
+
171
+
172
+ def _get_downloader() -> FileDownloader:
173
+ if _downloader is None:
174
+ raise RuntimeError("File downloader not initialized")
175
+ return _downloader
176
+
177
+
178
+ def _serialize(obj: Any) -> Any:
179
+ """Serialize Pydantic models and other objects to JSON-compatible dicts."""
180
+ if hasattr(obj, "model_dump"):
181
+ return obj.model_dump()
182
+ if isinstance(obj, list):
183
+ return [_serialize(item) for item in obj]
184
+ if isinstance(obj, dict):
185
+ return {k: _serialize(v) for k, v in obj.items()}
186
+ return obj
187
+
188
+
189
+ # ======================================================================
190
+ # Tool 1: Search Experiments
191
+ # ======================================================================
192
+
193
+
194
+ @mcp.tool(annotations=_READONLY_API, title="Search ENCODE Experiments")
195
+ async def encode_search_experiments(
196
+ assay_title: str | None = None,
197
+ organism: str = "Homo sapiens",
198
+ organ: str | None = None,
199
+ biosample_type: str | None = None,
200
+ biosample_term_name: str | None = None,
201
+ target: str | None = None,
202
+ status: str = "released",
203
+ lab: str | None = None,
204
+ award: str | None = None,
205
+ assembly: str | None = None,
206
+ replication_type: str | None = None,
207
+ life_stage: str | None = None,
208
+ sex: str | None = None,
209
+ treatment: str | None = None,
210
+ genetic_modification: str | None = None,
211
+ perturbed: bool | None = None,
212
+ search_term: str | None = None,
213
+ date_released_from: str | None = None,
214
+ date_released_to: str | None = None,
215
+ limit: int = 25,
216
+ offset: int = 0,
217
+ ) -> str:
218
+ """Search ENCODE experiments with comprehensive filters.
219
+
220
+ Examples:
221
+ - Find all Histone ChIP-seq on human pancreas tissue:
222
+ assay_title="Histone ChIP-seq", organ="pancreas", biosample_type="tissue"
223
+ - Find ATAC-seq on human brain:
224
+ assay_title="ATAC-seq", organ="brain"
225
+ - Find RNA-seq on GM12878 cell line:
226
+ assay_title="total RNA-seq", biosample_term_name="GM12878"
227
+ - Find ChIP-seq targeting H3K27me3:
228
+ assay_title="Histone ChIP-seq", target="H3K27me3"
229
+ - Find all mouse liver experiments:
230
+ organism="Mus musculus", organ="liver"
231
+ - Free text search:
232
+ search_term="CRISPR screen pancreatic"
233
+
234
+ Common assay_title values: "Histone ChIP-seq", "TF ChIP-seq", "ATAC-seq",
235
+ "DNase-seq", "total RNA-seq", "polyA plus RNA-seq", "WGBS", "intact Hi-C",
236
+ "CUT&RUN", "CUT&Tag", "STARR-seq", "MPRA", "eCLIP", "CRISPR screen"
237
+
238
+ Common organ values: "pancreas", "liver", "brain", "heart", "kidney",
239
+ "lung", "intestine", "skin of body", "blood", "spleen", "thymus"
240
+
241
+ biosample_type values: "tissue", "cell line", "primary cell",
242
+ "in vitro differentiated cells", "organoid"
243
+
244
+ WHEN TO USE: Use as the primary entry point when users want to find experiments.
245
+ Start with encode_get_facets if unsure what filters to use.
246
+ RELATED TOOLS: encode_get_facets, encode_get_metadata, encode_search_files
247
+
248
+ Args:
249
+ assay_title: Assay type (e.g., "Histone ChIP-seq", "ATAC-seq", "total RNA-seq")
250
+ organism: Species (default: "Homo sapiens"). Also: "Mus musculus"
251
+ organ: Organ/tissue system (e.g., "pancreas", "brain", "liver")
252
+ biosample_type: Sample classification ("tissue", "cell line", "primary cell", "organoid")
253
+ biosample_term_name: Specific cell/tissue name (e.g., "GM12878", "HepG2", "pancreas")
254
+ target: ChIP/CUT&RUN target (e.g., "H3K27me3", "CTCF", "p300")
255
+ status: Data status (default: "released"). Also: "archived", "revoked"
256
+ lab: Submitting lab name
257
+ award: Funding project
258
+ assembly: Genome assembly (e.g., "GRCh38", "mm10")
259
+ replication_type: "isogenic", "anisogenic", or "unreplicated"
260
+ life_stage: "embryonic", "postnatal", "child", "adult"
261
+ sex: "male", "female", "mixed"
262
+ treatment: Treatment name if perturbation experiment
263
+ genetic_modification: Modification type ("CRISPR", "RNAi")
264
+ perturbed: True for perturbation experiments only
265
+ search_term: Free text search across all fields
266
+ date_released_from: Start date (YYYY-MM-DD) for date range filter
267
+ date_released_to: End date (YYYY-MM-DD) for date range filter
268
+ limit: Max results to return (default 25, use larger for comprehensive searches)
269
+ offset: Skip first N results (for pagination)
270
+
271
+ Returns:
272
+ JSON with experiment results, total count, and pagination info.
273
+ """
274
+ client = await _get_client()
275
+ limit = clamp_limit(limit)
276
+
277
+ filter_warnings = _validate_filters(assay_title, organ, biosample_type)
278
+
279
+ result = await client.search_experiments(
280
+ assay_title=assay_title,
281
+ organism=organism,
282
+ organ=organ,
283
+ biosample_type=biosample_type,
284
+ biosample_term_name=biosample_term_name,
285
+ target=target,
286
+ status=status,
287
+ lab=lab,
288
+ award=award,
289
+ assembly=assembly,
290
+ replication_type=replication_type,
291
+ life_stage=life_stage,
292
+ sex=sex,
293
+ treatment=treatment,
294
+ genetic_modification=genetic_modification,
295
+ perturbed=perturbed,
296
+ search_term=search_term,
297
+ date_released_from=date_released_from,
298
+ date_released_to=date_released_to,
299
+ limit=limit,
300
+ offset=offset,
301
+ )
302
+ total = result.get("total", 0)
303
+ serialized = _serialize(result)
304
+ serialized["has_more"] = total > (offset + limit)
305
+ serialized["next_offset"] = offset + limit if total > (offset + limit) else None
306
+ if filter_warnings:
307
+ serialized["filter_warnings"] = filter_warnings
308
+ if not result.get("results"):
309
+ serialized["suggestion"] = (
310
+ "Try broadening your search filters. Use encode_get_facets to see what data is available for your criteria."
311
+ )
312
+ return json.dumps(serialized, indent=2)
313
+
314
+
315
+ # ======================================================================
316
+ # Tool 2: Get Experiment Details
317
+ # ======================================================================
318
+
319
+
320
+ @mcp.tool(annotations=_READONLY_API, title="Get Experiment Details")
321
+ async def encode_get_experiment(accession: str) -> str:
322
+ """Get full details for a specific ENCODE experiment by accession ID.
323
+
324
+ Returns complete experiment metadata including all associated files,
325
+ quality metrics, controls, replicate information, and audit status.
326
+
327
+ WHEN TO USE: Use when you have a specific accession and need full details
328
+ including files, quality metrics, and audit status.
329
+ RELATED TOOLS: encode_list_files, encode_track_experiment, encode_compare_experiments
330
+
331
+ Args:
332
+ accession: ENCODE experiment accession (e.g., "ENCSR133RZO", "ENCSR000AKS")
333
+
334
+ Returns:
335
+ JSON with full experiment details and file listing.
336
+ """
337
+ validate_accession(accession)
338
+ client = await _get_client()
339
+ result = await client.get_experiment(accession)
340
+ return json.dumps(_serialize(result), indent=2)
341
+
342
+
343
+ # ======================================================================
344
+ # Tool 3: List Files for Experiment
345
+ # ======================================================================
346
+
347
+
348
+ @mcp.tool(annotations=_READONLY_API, title="List Experiment Files")
349
+ async def encode_list_files(
350
+ experiment_accession: str,
351
+ file_format: str | None = None,
352
+ file_type: str | None = None,
353
+ output_type: str | None = None,
354
+ output_category: str | None = None,
355
+ assembly: str | None = None,
356
+ status: str | None = None,
357
+ preferred_default: bool | None = None,
358
+ limit: int = 200,
359
+ ) -> str:
360
+ """List all files for a specific ENCODE experiment, with optional filters.
361
+
362
+ Examples:
363
+ - All BED files: experiment_accession="ENCSR133RZO", file_format="bed"
364
+ - FASTQs only: experiment_accession="ENCSR133RZO", file_format="fastq"
365
+ - Signal tracks: experiment_accession="ENCSR133RZO", output_category="signal"
366
+ - Default/recommended files: preferred_default=True
367
+ - Peaks from GRCh38: file_format="bed", output_type="IDR thresholded peaks", assembly="GRCh38"
368
+
369
+ Common file_format values: "fastq", "bam", "bed", "bigWig", "bigBed", "tsv", "hic"
370
+
371
+ Common output_type values: "reads", "alignments", "signal of unique reads",
372
+ "signal of all reads", "fold change over control", "IDR thresholded peaks",
373
+ "pseudoreplicated peaks", "replicated peaks", "gene quantifications",
374
+ "transcript quantifications", "contact matrix"
375
+
376
+ WHEN TO USE: Use to browse files within a known experiment. Use encode_search_files
377
+ instead to find files across experiments.
378
+ RELATED TOOLS: encode_search_files, encode_get_file_info, encode_download_files
379
+
380
+ Args:
381
+ experiment_accession: ENCODE experiment accession (e.g., "ENCSR133RZO")
382
+ file_format: Filter by format ("fastq", "bam", "bed", "bigWig", "bigBed", etc.)
383
+ file_type: Filter by specific type ("bed narrowPeak", "bed broadPeak", etc.)
384
+ output_type: Filter by output type ("reads", "peaks", "signal", etc.)
385
+ output_category: Filter by category ("raw data", "alignment", "signal", "annotation")
386
+ assembly: Filter by genome assembly ("GRCh38", "hg19", "mm10")
387
+ status: Filter by status ("released", "archived", "in progress")
388
+ preferred_default: If True, return only default/recommended files
389
+ limit: Max files to return (default 200)
390
+
391
+ Returns:
392
+ JSON list of files with accession, format, size, download URL, and metadata.
393
+ """
394
+ validate_accession(experiment_accession)
395
+ limit = clamp_limit(limit)
396
+ client = await _get_client()
397
+ results = await client.list_files(
398
+ experiment_accession=experiment_accession,
399
+ file_format=file_format,
400
+ file_type=file_type,
401
+ output_type=output_type,
402
+ output_category=output_category,
403
+ assembly=assembly,
404
+ status=status,
405
+ preferred_default=preferred_default,
406
+ limit=limit,
407
+ )
408
+ return json.dumps(_serialize(results), indent=2)
409
+
410
+
411
+ # ======================================================================
412
+ # Tool 4: Search Files Across Experiments
413
+ # ======================================================================
414
+
415
+
416
+ @mcp.tool(annotations=_READONLY_API, title="Search Files Across Experiments")
417
+ async def encode_search_files(
418
+ file_format: str | None = None,
419
+ file_type: str | None = None,
420
+ output_type: str | None = None,
421
+ output_category: str | None = None,
422
+ assembly: str | None = None,
423
+ assay_title: str | None = None,
424
+ organism: str | None = None,
425
+ organ: str | None = None,
426
+ biosample_type: str | None = None,
427
+ target: str | None = None,
428
+ status: str = "released",
429
+ preferred_default: bool | None = None,
430
+ search_term: str | None = None,
431
+ limit: int = 25,
432
+ offset: int = 0,
433
+ ) -> str:
434
+ """Search files across ALL experiments with combined experiment + file filters.
435
+
436
+ This is powerful for finding specific file types across many experiments.
437
+
438
+ Examples:
439
+ - All BED files from human pancreas ChIP-seq:
440
+ file_format="bed", assay_title="Histone ChIP-seq", organ="pancreas"
441
+ - FASTQs from mouse liver RNA-seq:
442
+ file_format="fastq", assay_title="total RNA-seq", organ="liver", organism="Mus musculus"
443
+ - All IDR peak files for H3K27me3:
444
+ output_type="IDR thresholded peaks", target="H3K27me3"
445
+ - BigWig signal tracks from ATAC-seq on brain tissue:
446
+ file_format="bigWig", assay_title="ATAC-seq", organ="brain", biosample_type="tissue"
447
+
448
+ WHEN TO USE: Use to find specific file types across ALL experiments. More powerful
449
+ than encode_list_files for cross-experiment file discovery.
450
+ RELATED TOOLS: encode_list_files, encode_batch_download, encode_get_file_info
451
+
452
+ Args:
453
+ file_format: File format ("fastq", "bam", "bed", "bigWig", etc.)
454
+ file_type: Specific file type ("bed narrowPeak", "bed broadPeak", etc.)
455
+ output_type: Output type ("reads", "peaks", "signal", etc.)
456
+ output_category: Output category ("raw data", "alignment", "signal", "annotation")
457
+ assembly: Genome assembly ("GRCh38", "hg19", "mm10")
458
+ assay_title: Filter by assay type of parent experiment
459
+ organism: Filter by organism of parent experiment
460
+ organ: Filter by organ of parent experiment
461
+ biosample_type: Filter by biosample type ("tissue", "cell line", etc.)
462
+ target: Filter by ChIP/CUT&RUN target
463
+ status: File status (default: "released")
464
+ preferred_default: If True, only default/recommended files
465
+ search_term: Free text search
466
+ limit: Max results (default 25)
467
+ offset: Skip first N results (pagination)
468
+
469
+ Returns:
470
+ JSON with file results, total count, and pagination info.
471
+ """
472
+ client = await _get_client()
473
+ limit = clamp_limit(limit)
474
+ filter_warnings = _validate_filters(assay_title, organ, biosample_type)
475
+ result = await client.search_files(
476
+ file_format=file_format,
477
+ file_type=file_type,
478
+ output_type=output_type,
479
+ output_category=output_category,
480
+ assembly=assembly,
481
+ assay_title=assay_title,
482
+ organism=organism,
483
+ organ=organ,
484
+ biosample_type=biosample_type,
485
+ target=target,
486
+ status=status,
487
+ preferred_default=preferred_default,
488
+ search_term=search_term,
489
+ limit=limit,
490
+ offset=offset,
491
+ )
492
+ total = result.get("total", 0)
493
+ serialized = _serialize(result)
494
+ serialized["has_more"] = total > (offset + limit)
495
+ serialized["next_offset"] = offset + limit if total > (offset + limit) else None
496
+ if filter_warnings:
497
+ serialized["filter_warnings"] = filter_warnings
498
+ if not result.get("results"):
499
+ serialized["suggestion"] = (
500
+ "Verify assembly and file_format values. Use encode_get_metadata('file_formats') to see valid options."
501
+ )
502
+ return json.dumps(serialized, indent=2)
503
+
504
+
505
+ # ======================================================================
506
+ # Tool 5: Download Files
507
+ # ======================================================================
508
+
509
+
510
+ @mcp.tool(annotations=_DOWNLOAD, title="Download ENCODE Files")
511
+ async def encode_download_files(
512
+ file_accessions: list[str],
513
+ download_dir: str,
514
+ organize_by: Literal["flat", "experiment", "format", "experiment_format"] = "flat",
515
+ verify_md5: bool = True,
516
+ ) -> str:
517
+ """Download specific ENCODE files by accession to a local directory.
518
+
519
+ Downloads files from ENCODE to your local machine. Supports MD5 verification,
520
+ concurrent downloads, and skip-if-already-downloaded.
521
+
522
+ WHEN TO USE: Use for downloading specific files by accession. For bulk downloads,
523
+ prefer encode_batch_download.
524
+ RELATED TOOLS: encode_batch_download, encode_search_files, encode_log_derived_file
525
+
526
+ Args:
527
+ file_accessions: List of file accessions to download (e.g., ["ENCFF635JIA", "ENCFF388RZD"])
528
+ download_dir: Local directory path to save files (e.g., "./data/encode")
529
+ organize_by: How to organize downloaded files:
530
+ - "flat": All files in download_dir (default)
531
+ - "experiment": download_dir/ENCSR.../filename
532
+ - "format": download_dir/bed/filename
533
+ - "experiment_format": download_dir/ENCSR.../bed/filename
534
+ verify_md5: Verify file integrity with MD5 checksum (default True)
535
+
536
+ Returns:
537
+ JSON with download results for each file (path, size, success/error, MD5 status).
538
+ """
539
+ validate_organize_by(organize_by)
540
+ for acc in file_accessions:
541
+ validate_accession(acc)
542
+
543
+ client = await _get_client()
544
+ downloader = _get_downloader()
545
+
546
+ # Get file info for each accession
547
+ file_infos = []
548
+ errors = []
549
+ for acc in file_accessions:
550
+ try:
551
+ info = await client.get_file_info(acc)
552
+ file_infos.append(info)
553
+ except Exception as e:
554
+ errors.append({"accession": acc, "error": str(e)})
555
+
556
+ # Download all files
557
+ results = await downloader.download_batch(file_infos, download_dir, organize_by, verify_md5)
558
+
559
+ output = {
560
+ "downloaded": _serialize(results),
561
+ "errors": errors,
562
+ "summary": {
563
+ "total_requested": len(file_accessions),
564
+ "successful": sum(1 for r in results if r.success),
565
+ "failed": sum(1 for r in results if not r.success) + len(errors),
566
+ "total_size": sum(r.file_size for r in results if r.success),
567
+ "total_size_human": _human_size(sum(r.file_size for r in results if r.success)),
568
+ },
569
+ }
570
+ return json.dumps(output, indent=2)
571
+
572
+
573
+ # ======================================================================
574
+ # Tool 6: Get Metadata / Filter Values
575
+ # ======================================================================
576
+
577
+
578
+ @mcp.tool(annotations=_READONLY_API, title="Get Filter Values")
579
+ async def encode_get_metadata(
580
+ metadata_type: Literal[
581
+ "assays",
582
+ "organisms",
583
+ "organs",
584
+ "biosample_types",
585
+ "file_formats",
586
+ "output_types",
587
+ "output_categories",
588
+ "assemblies",
589
+ "life_stages",
590
+ "replication_types",
591
+ "statuses",
592
+ "file_statuses",
593
+ ],
594
+ ) -> str:
595
+ """Get available filter values for ENCODE searches.
596
+
597
+ Use this to discover valid values for search parameters.
598
+
599
+ WHEN TO USE: Use to discover valid filter values before searching. Helps prevent
600
+ typos in assay_title, organ, biosample_type etc.
601
+ RELATED TOOLS: encode_get_facets, encode_search_experiments
602
+
603
+ Args:
604
+ metadata_type: Type of metadata to retrieve. Options:
605
+ - "assays": Available assay types (Histone ChIP-seq, ATAC-seq, total RNA-seq, etc.)
606
+ - "organisms": Available organisms (Homo sapiens, Mus musculus, etc.)
607
+ - "organs": Available organ/tissue systems (pancreas, brain, liver, etc.)
608
+ - "biosample_types": Biosample classifications (tissue, cell line, primary cell, etc.)
609
+ - "file_formats": File format types (fastq, bam, bed, bigWig, etc.)
610
+ - "output_types": Output data types (reads, peaks, signal, etc.)
611
+ - "output_categories": Output categories (raw data, alignment, signal, etc.)
612
+ - "assemblies": Genome assemblies (GRCh38, hg19, mm10, etc.)
613
+ - "life_stages": Life stages (embryonic, adult, child, etc.)
614
+ - "replication_types": Replication types (isogenic, anisogenic, unreplicated)
615
+ - "statuses": Experiment statuses (released, archived, etc.)
616
+ - "file_statuses": File statuses (released, archived, in progress, etc.)
617
+
618
+ Returns:
619
+ JSON list of valid values for the specified metadata type.
620
+ """
621
+ client = await _get_client()
622
+ try:
623
+ values = client.get_metadata(metadata_type)
624
+ return json.dumps({"metadata_type": metadata_type, "values": values, "count": len(values)}, indent=2)
625
+ except ValueError as e:
626
+ return json.dumps({"error": str(e)}, indent=2)
627
+
628
+
629
+ # ======================================================================
630
+ # Tool 7: Batch Download from Search
631
+ # ======================================================================
632
+
633
+
634
+ @mcp.tool(annotations=_DOWNLOAD, title="Batch Search and Download")
635
+ async def encode_batch_download(
636
+ download_dir: str,
637
+ file_format: str | None = None,
638
+ output_type: str | None = None,
639
+ output_category: str | None = None,
640
+ assembly: str | None = None,
641
+ assay_title: str | None = None,
642
+ organism: str = "Homo sapiens",
643
+ organ: str | None = None,
644
+ biosample_type: str | None = None,
645
+ target: str | None = None,
646
+ preferred_default: bool | None = None,
647
+ organize_by: Literal["flat", "experiment", "format", "experiment_format"] = "experiment",
648
+ verify_md5: bool = True,
649
+ limit: int = 100,
650
+ dry_run: bool = True,
651
+ ) -> str:
652
+ """Search for files and download them all in batch.
653
+
654
+ First searches for files matching the criteria, then downloads them.
655
+ By default runs in dry_run mode to preview what would be downloaded.
656
+ Set dry_run=False to actually download.
657
+
658
+ WHEN TO USE: Use for searching and downloading files in one step. Always use
659
+ dry_run=True first to preview. For specific file accessions, use encode_download_files.
660
+ RELATED TOOLS: encode_download_files, encode_search_files
661
+
662
+ Examples:
663
+ - Download all BED files from human pancreas ChIP-seq:
664
+ file_format="bed", assay_title="Histone ChIP-seq", organ="pancreas",
665
+ download_dir="/data/encode", dry_run=False
666
+ - Preview FASTQ downloads for mouse brain RNA-seq:
667
+ file_format="fastq", assay_title="total RNA-seq", organ="brain",
668
+ organism="Mus musculus", download_dir="/data/encode"
669
+ - Download IDR peaks for H3K27me3 in GRCh38:
670
+ output_type="IDR thresholded peaks", target="H3K27me3", assembly="GRCh38",
671
+ download_dir="/data/encode", dry_run=False
672
+
673
+ Args:
674
+ download_dir: Local directory to save files
675
+ file_format: File format filter ("fastq", "bam", "bed", "bigWig", etc.)
676
+ output_type: Output type filter ("reads", "peaks", "signal", etc.)
677
+ output_category: Output category ("raw data", "alignment", "annotation", etc.)
678
+ assembly: Genome assembly ("GRCh38", "mm10", etc.)
679
+ assay_title: Assay type ("Histone ChIP-seq", "ATAC-seq", "total RNA-seq", etc.)
680
+ organism: Organism (default: "Homo sapiens")
681
+ organ: Organ/tissue ("pancreas", "brain", "liver", etc.)
682
+ biosample_type: Biosample type ("tissue", "cell line", "primary cell", etc.)
683
+ target: ChIP/CUT&RUN target ("H3K27me3", "CTCF", etc.)
684
+ preferred_default: If True, only download default/recommended files
685
+ organize_by: File organization ("flat", "experiment", "format", "experiment_format")
686
+ verify_md5: Verify downloads with MD5 checksums (default True)
687
+ limit: Max files to download (default 100, safety limit)
688
+ dry_run: If True (default), only preview what would be downloaded. Set False to download.
689
+
690
+ Returns:
691
+ JSON with download preview (dry_run=True) or download results (dry_run=False).
692
+ """
693
+ client = await _get_client()
694
+ downloader = _get_downloader()
695
+ validate_organize_by(organize_by)
696
+ limit = clamp_limit(limit)
697
+ filter_warnings = _validate_filters(assay_title, organ, biosample_type)
698
+
699
+ # Search for files
700
+ search_result = await client.search_files(
701
+ file_format=file_format,
702
+ output_type=output_type,
703
+ output_category=output_category,
704
+ assembly=assembly,
705
+ assay_title=assay_title,
706
+ organism=organism,
707
+ organ=organ,
708
+ biosample_type=biosample_type,
709
+ target=target,
710
+ status="released",
711
+ preferred_default=preferred_default,
712
+ limit=limit,
713
+ )
714
+
715
+ files = search_result["results"]
716
+
717
+ if not files:
718
+ empty_result = {
719
+ "message": "No files found matching the search criteria.",
720
+ "total": 0,
721
+ "has_more": False,
722
+ "next_offset": None,
723
+ "suggestion": "Try broadening your search filters. Use encode_get_facets to see what data is available for your criteria.",
724
+ }
725
+ if filter_warnings:
726
+ empty_result["filter_warnings"] = filter_warnings
727
+ return json.dumps(empty_result, indent=2)
728
+
729
+ if dry_run:
730
+ # Preview mode
731
+ search_total = search_result["total"]
732
+ preview = downloader.preview_downloads(files, download_dir, organize_by)
733
+ preview["message"] = (
734
+ f"Found {preview['file_count']} files ({preview['total_size_human']}). Set dry_run=False to download."
735
+ )
736
+ preview["search_total"] = search_total
737
+ preview["has_more"] = search_total > limit
738
+ preview["next_offset"] = limit if search_total > limit else None
739
+ if filter_warnings:
740
+ preview["filter_warnings"] = filter_warnings
741
+ return json.dumps(_serialize(preview), indent=2)
742
+
743
+ # Actually download
744
+ results = await downloader.download_batch(files, download_dir, organize_by, verify_md5)
745
+ search_total = search_result["total"]
746
+
747
+ output = {
748
+ "downloaded": _serialize(results),
749
+ "summary": {
750
+ "total_found": search_total,
751
+ "total_downloaded": len(results),
752
+ "successful": sum(1 for r in results if r.success),
753
+ "failed": sum(1 for r in results if not r.success),
754
+ "total_size": sum(r.file_size for r in results if r.success),
755
+ "total_size_human": _human_size(sum(r.file_size for r in results if r.success)),
756
+ },
757
+ "has_more": search_total > limit,
758
+ "next_offset": limit if search_total > limit else None,
759
+ }
760
+ if filter_warnings:
761
+ output["filter_warnings"] = filter_warnings
762
+ return json.dumps(output, indent=2)
763
+
764
+
765
+ # ======================================================================
766
+ # Tool 8: Store/Manage Credentials
767
+ # ======================================================================
768
+
769
+
770
+ @mcp.tool(annotations=_CREDENTIAL_MGMT, title="Manage API Credentials")
771
+ async def encode_manage_credentials(
772
+ action: Literal["store", "check", "clear"],
773
+ access_key: str | None = None,
774
+ secret_key: str | None = None,
775
+ ) -> str:
776
+ """Manage ENCODE API credentials for accessing restricted/unreleased data.
777
+
778
+ Most ENCODE data is public and requires no authentication.
779
+ Credentials are only needed for unreleased or restricted datasets.
780
+
781
+ Credentials are stored securely in your OS keyring (macOS Keychain,
782
+ Linux Secret Service, Windows Credential Locker) and never in plaintext.
783
+
784
+ WHEN TO USE: Use only for accessing unreleased/restricted ENCODE data.
785
+ Public data requires no authentication.
786
+ RELATED TOOLS: encode_search_experiments
787
+
788
+ Args:
789
+ action: What to do:
790
+ - "store": Save new credentials (requires access_key and secret_key)
791
+ - "check": Check if credentials are configured
792
+ - "clear": Remove stored credentials
793
+ access_key: Your ENCODE access key (only for action="store")
794
+ secret_key: Your ENCODE secret key (only for action="store")
795
+
796
+ Returns:
797
+ JSON with action result.
798
+ """
799
+ if action == "store":
800
+ if not access_key or not secret_key:
801
+ return json.dumps(
802
+ {
803
+ "error": "Both access_key and secret_key are required to store credentials.",
804
+ "help": "Get your access key pair from your ENCODE profile at https://www.encodeproject.org/",
805
+ },
806
+ indent=2,
807
+ )
808
+
809
+ location = _credential_manager.store_credentials(access_key, secret_key)
810
+ # Reset client to pick up new credentials
811
+ global _client
812
+ async with _get_client_lock():
813
+ if _client:
814
+ await _client.close()
815
+ _client = EncodeClient(credential_manager=_credential_manager)
816
+
817
+ return json.dumps(
818
+ {
819
+ "success": True,
820
+ "message": f"Credentials stored securely in: {location}",
821
+ "note": "Credentials are encrypted and never stored in plaintext.",
822
+ },
823
+ indent=2,
824
+ )
825
+
826
+ elif action == "check":
827
+ has_creds = _credential_manager.has_credentials
828
+ return json.dumps(
829
+ {
830
+ "credentials_configured": has_creds,
831
+ "message": (
832
+ "Credentials are configured. You can access restricted data."
833
+ if has_creds
834
+ else "No credentials configured. You can still access all public ENCODE data. "
835
+ "Use action='store' with your ENCODE access key pair to access restricted data."
836
+ ),
837
+ },
838
+ indent=2,
839
+ )
840
+
841
+ elif action == "clear":
842
+ _credential_manager.clear_credentials()
843
+ # Reset client
844
+ async with _get_client_lock():
845
+ if _client: # type: ignore[used-before-def]
846
+ await _client.close() # type: ignore[used-before-def]
847
+ _client = EncodeClient(credential_manager=_credential_manager)
848
+
849
+ return json.dumps(
850
+ {
851
+ "success": True,
852
+ "message": "All stored credentials have been removed.",
853
+ },
854
+ indent=2,
855
+ )
856
+
857
+ else:
858
+ return json.dumps(
859
+ {
860
+ "error": f"Unknown action: {action}. Use 'store', 'check', or 'clear'.",
861
+ },
862
+ indent=2,
863
+ )
864
+
865
+
866
+ # ======================================================================
867
+ # Tool 9: Get Live Facets (Dynamic Filter Discovery)
868
+ # ======================================================================
869
+
870
+
871
+ @mcp.tool(annotations=_READONLY_API, title="Explore Available Data")
872
+ async def encode_get_facets(
873
+ search_type: str = "Experiment",
874
+ assay_title: str | None = None,
875
+ organism: str | None = None,
876
+ organ: str | None = None,
877
+ biosample_type: str | None = None,
878
+ ) -> str:
879
+ """Get live filter counts from ENCODE to discover what data is available.
880
+
881
+ Returns faceted counts showing how many experiments/files exist for each
882
+ filter value. Useful for exploring what's available before searching.
883
+
884
+ WHEN TO USE: Use to explore what data exists before searching. Shows counts
885
+ per filter value. Best first step for unknown datasets.
886
+ RELATED TOOLS: encode_get_metadata, encode_search_experiments
887
+
888
+ Examples:
889
+ - What assays are available for pancreas?
890
+ organ="pancreas"
891
+ - What organs have Histone ChIP-seq data?
892
+ assay_title="Histone ChIP-seq"
893
+ - What targets are available for mouse brain ChIP-seq?
894
+ assay_title="Histone ChIP-seq", organism="Mus musculus", organ="brain"
895
+
896
+ Args:
897
+ search_type: Object type ("Experiment" or "File")
898
+ assay_title: Pre-filter by assay type
899
+ organism: Pre-filter by organism
900
+ organ: Pre-filter by organ
901
+ biosample_type: Pre-filter by biosample type
902
+
903
+ Returns:
904
+ JSON with facet names and their term counts.
905
+ """
906
+ client = await _get_client()
907
+ filter_warnings = _validate_filters(assay_title, organ, biosample_type)
908
+ filters = {}
909
+ if assay_title:
910
+ filters["assay_title"] = assay_title
911
+ if organism:
912
+ filters["replicates.library.biosample.donor.organism.scientific_name"] = organism
913
+ if organ:
914
+ filters["biosample_ontology.organ_slims"] = organ
915
+ if biosample_type:
916
+ filters["biosample_ontology.classification"] = biosample_type
917
+
918
+ facets = await client.search_facets(search_type=search_type, **filters)
919
+
920
+ # Simplify output - show most useful facets
921
+ useful_facets = {}
922
+ for field, terms in facets.items():
923
+ # Only include facets with reasonable number of terms
924
+ if len(terms) <= 200:
925
+ useful_facets[field] = terms[:50] # Cap at 50 terms per facet
926
+
927
+ result: dict = dict(useful_facets)
928
+ if filter_warnings:
929
+ result["filter_warnings"] = filter_warnings
930
+ return json.dumps(result, indent=2)
931
+
932
+
933
+ # ======================================================================
934
+ # Tool 10: Get File Info
935
+ # ======================================================================
936
+
937
+
938
+ @mcp.tool(annotations=_READONLY_API, title="Get File Details")
939
+ async def encode_get_file_info(accession: str) -> str:
940
+ """Get detailed information about a specific ENCODE file.
941
+
942
+ WHEN TO USE: Use when you need detailed metadata for a specific file
943
+ (size, md5, assembly, biological replicate info).
944
+ RELATED TOOLS: encode_download_files, encode_list_files
945
+
946
+ Args:
947
+ accession: File accession ID (e.g., "ENCFF635JIA")
948
+
949
+ Returns:
950
+ JSON with file metadata including format, size, download URL, MD5, assembly, etc.
951
+ """
952
+ validate_accession(accession)
953
+ client = await _get_client()
954
+ info = await client.get_file_info(accession)
955
+ return json.dumps(_serialize(info), indent=2)
956
+
957
+
958
+ # ======================================================================
959
+ # Tracker helper
960
+ # ======================================================================
961
+
962
+
963
+ def _get_tracker() -> ExperimentTracker:
964
+ if _tracker is None:
965
+ raise RuntimeError("Experiment tracker not initialized")
966
+ return _tracker
967
+
968
+
969
+ # ======================================================================
970
+ # Tool 11: Track Experiment
971
+ # ======================================================================
972
+
973
+
974
+ @mcp.tool(annotations=_WRITE_LOCAL_API, title="Track Experiment Locally")
975
+ async def encode_track_experiment(
976
+ accession: str,
977
+ fetch_publications: bool = True,
978
+ fetch_pipelines: bool = True,
979
+ notes: str = "",
980
+ ) -> str:
981
+ """Track an ENCODE experiment locally with its publications, methods, and pipeline info.
982
+
983
+ Fetches full experiment metadata from ENCODE and stores it in a local SQLite
984
+ database along with any associated publications (PMIDs, DOIs, authors, journal)
985
+ and pipeline/analysis information (software versions, methods).
986
+
987
+ This is like adding an experiment to your "library" - similar to Endnote for papers.
988
+
989
+ WHEN TO USE: Use to save an experiment to your local library with publications
990
+ and pipeline info. Required before compare or citations.
991
+ RELATED TOOLS: encode_compare_experiments, encode_get_citations, encode_export_data
992
+
993
+ Args:
994
+ accession: ENCODE experiment accession (e.g., "ENCSR133RZO")
995
+ fetch_publications: Also fetch and store publications/citations (default True)
996
+ fetch_pipelines: Also fetch and store pipeline/analysis info (default True)
997
+ notes: Optional notes to attach to this experiment
998
+
999
+ Returns:
1000
+ JSON with tracking result including publications and pipeline info found.
1001
+ """
1002
+ client = await _get_client()
1003
+ tracker = _get_tracker()
1004
+ validate_accession(accession)
1005
+
1006
+ # Get full experiment data from ENCODE
1007
+ exp_data = await client.get_experiment_raw(accession)
1008
+
1009
+ # Track the experiment
1010
+ from encode_connector.client.models import ExperimentDetail
1011
+
1012
+ detail = ExperimentDetail.from_api(exp_data)
1013
+ result = tracker.track_experiment(detail.model_dump(), raw_metadata=exp_data)
1014
+
1015
+ if notes:
1016
+ tracker.add_note(accession, notes)
1017
+
1018
+ output: dict[str, Any] = {"tracking": result}
1019
+
1020
+ # Auto-link cross-references from dbxrefs (GEO, PMID, etc.)
1021
+ dbxrefs = exp_data.get("dbxrefs", []) or []
1022
+ auto_linked = []
1023
+ for xref in dbxrefs:
1024
+ if not isinstance(xref, str):
1025
+ continue
1026
+ if xref.startswith("GEO:"):
1027
+ ref_result = tracker.link_reference(
1028
+ accession,
1029
+ "geo_accession",
1030
+ xref.replace("GEO:", ""),
1031
+ "Auto-extracted from ENCODE dbxrefs",
1032
+ )
1033
+ if ref_result.get("action") == "linked":
1034
+ auto_linked.append({"type": "geo_accession", "id": xref.replace("GEO:", "")})
1035
+ elif xref.startswith("PMID:"):
1036
+ ref_result = tracker.link_reference(
1037
+ accession,
1038
+ "pmid",
1039
+ xref.replace("PMID:", ""),
1040
+ "Auto-extracted from ENCODE dbxrefs",
1041
+ )
1042
+ if ref_result.get("action") == "linked":
1043
+ auto_linked.append({"type": "pmid", "id": xref.replace("PMID:", "")})
1044
+ if auto_linked:
1045
+ output["auto_linked_references"] = auto_linked
1046
+
1047
+ # Fetch publications
1048
+ if fetch_publications:
1049
+ refs = exp_data.get("references", [])
1050
+ # If references are paths, fetch them (validate to prevent SSRF)
1051
+ resolved_refs = []
1052
+ for ref in refs:
1053
+ if isinstance(ref, str):
1054
+ try:
1055
+ safe_path = validate_encode_path(ref)
1056
+ ref_data = await client.get_json(safe_path, {"format": "json"})
1057
+ resolved_refs.append(ref_data)
1058
+ except Exception as e:
1059
+ logger.warning("Skipping ref %r: %s", ref, e)
1060
+ elif isinstance(ref, dict):
1061
+ resolved_refs.append(ref)
1062
+
1063
+ pubs = parse_encode_publications(resolved_refs)
1064
+ pub_count = tracker.store_publications(accession, pubs)
1065
+ output["publications_found"] = pub_count
1066
+ output["publications"] = pubs
1067
+
1068
+ # Fetch pipeline info
1069
+ if fetch_pipelines:
1070
+ analyses = exp_data.get("analyses", [])
1071
+ resolved_analyses = []
1072
+ for analysis in analyses:
1073
+ if isinstance(analysis, str):
1074
+ try:
1075
+ safe_path = validate_encode_path(analysis)
1076
+ a_data = await client.get_json(safe_path, {"format": "json"})
1077
+ resolved_analyses.append(a_data)
1078
+ except Exception as e:
1079
+ logger.warning("Skipping ref %r: %s", analysis, e)
1080
+ elif isinstance(analysis, dict):
1081
+ resolved_analyses.append(analysis)
1082
+
1083
+ pipelines = parse_encode_pipelines(resolved_analyses)
1084
+ pipe_count = tracker.store_pipeline_info(accession, pipelines)
1085
+ output["pipelines_found"] = pipe_count
1086
+ output["pipelines"] = pipelines
1087
+
1088
+ return json.dumps(output, indent=2)
1089
+
1090
+
1091
+ # ======================================================================
1092
+ # Tool 12: List Tracked Experiments
1093
+ # ======================================================================
1094
+
1095
+
1096
+ @mcp.tool(annotations=_READONLY_LOCAL, title="List Tracked Experiments")
1097
+ async def encode_list_tracked(
1098
+ assay_title: str | None = None,
1099
+ organism: str | None = None,
1100
+ organ: str | None = None,
1101
+ ) -> str:
1102
+ """List all experiments you've tracked locally, with optional filters.
1103
+
1104
+ Shows your local library of tracked ENCODE experiments, their metadata,
1105
+ publication counts, and derived file counts.
1106
+
1107
+ WHEN TO USE: Use to see all experiments in your local library. Filter by assay,
1108
+ organism, or organ.
1109
+ RELATED TOOLS: encode_summarize_collection, encode_export_data
1110
+
1111
+ Args:
1112
+ assay_title: Filter by assay type (partial match)
1113
+ organism: Filter by organism (partial match)
1114
+ organ: Filter by organ (partial match)
1115
+
1116
+ Returns:
1117
+ JSON with tracked experiments metadata table and tracker stats.
1118
+ """
1119
+ tracker = _get_tracker()
1120
+ experiments = tracker.list_tracked_experiments(
1121
+ assay_title=assay_title,
1122
+ organism=organism,
1123
+ organ=organ,
1124
+ )
1125
+
1126
+ # Build metadata table
1127
+ table = tracker.get_metadata_table([e["accession"] for e in experiments] if experiments else None)
1128
+
1129
+ # Remove raw_metadata from output
1130
+ for row in table:
1131
+ row.pop("raw_metadata", None)
1132
+
1133
+ return json.dumps(
1134
+ {
1135
+ "experiments": table,
1136
+ "count": len(table),
1137
+ "stats": tracker.stats,
1138
+ },
1139
+ indent=2,
1140
+ )
1141
+
1142
+
1143
+ # ======================================================================
1144
+ # Tool 13: Get Experiment Publications & Citations
1145
+ # ======================================================================
1146
+
1147
+
1148
+ @mcp.tool(annotations=_READONLY_LOCAL, title="Get Citations")
1149
+ async def encode_get_citations(
1150
+ accession: str | None = None,
1151
+ export_format: Literal["json", "bibtex", "ris"] = "json",
1152
+ ) -> str:
1153
+ """Get publications and citations for tracked experiments.
1154
+
1155
+ Returns publication data with authors, journal, DOI, PMID.
1156
+ Can export as BibTeX or RIS (Endnote/Zotero/Mendeley compatible).
1157
+
1158
+ WHEN TO USE: Use to get publication data for tracked experiments. Supports
1159
+ BibTeX and RIS export for reference managers.
1160
+ RELATED TOOLS: encode_track_experiment, encode_link_reference
1161
+
1162
+ Args:
1163
+ accession: Specific experiment accession. If None, returns all publications.
1164
+ export_format: Output format:
1165
+ - "json": Structured data (default)
1166
+ - "bibtex": BibTeX format for LaTeX
1167
+ - "ris": RIS format (Endnote, Zotero, Mendeley)
1168
+
1169
+ Returns:
1170
+ Publications in the requested format.
1171
+ """
1172
+ validate_export_format(export_format)
1173
+ if accession:
1174
+ validate_accession(accession)
1175
+ tracker = _get_tracker()
1176
+
1177
+ if export_format == "bibtex":
1178
+ bibtex = tracker.export_citations_bibtex([accession] if accession else None)
1179
+ return bibtex if bibtex else "No publications found."
1180
+
1181
+ if export_format == "ris":
1182
+ ris = tracker.export_citations_ris([accession] if accession else None)
1183
+ return ris if ris else "No publications found."
1184
+
1185
+ # JSON format
1186
+ if accession:
1187
+ pubs = tracker.get_publications(accession)
1188
+ else:
1189
+ # Get all from all tracked experiments
1190
+ experiments = tracker.list_tracked_experiments()
1191
+ pubs = []
1192
+ for exp in experiments:
1193
+ exp_pubs = tracker.get_publications(exp["accession"])
1194
+ pubs.extend(exp_pubs)
1195
+
1196
+ return json.dumps(
1197
+ {
1198
+ "publications": pubs,
1199
+ "count": len(pubs),
1200
+ },
1201
+ indent=2,
1202
+ default=str,
1203
+ )
1204
+
1205
+
1206
+ # ======================================================================
1207
+ # Tool 14: Compare Experiments (Compatibility Analysis)
1208
+ # ======================================================================
1209
+
1210
+
1211
+ @mcp.tool(annotations=_READONLY_LOCAL, title="Compare Experiments")
1212
+ async def encode_compare_experiments(
1213
+ accession1: str,
1214
+ accession2: str,
1215
+ ) -> str:
1216
+ """Analyze whether two ENCODE experiments are compatible for combined analysis.
1217
+
1218
+ Compares organism, genome assembly, assay type, biosample, organ, target,
1219
+ replication strategy, and lab to identify potential issues.
1220
+
1221
+ Both experiments must be tracked first (use encode_track_experiment).
1222
+
1223
+ WHEN TO USE: Use to check if two experiments are compatible for combined analysis.
1224
+ Both must be tracked first.
1225
+ RELATED TOOLS: encode_track_experiment, encode_list_tracked
1226
+
1227
+ Args:
1228
+ accession1: First experiment accession (e.g., "ENCSR133RZO")
1229
+ accession2: Second experiment accession (e.g., "ENCSR000AKS")
1230
+
1231
+ Returns:
1232
+ JSON compatibility report with verdict, issues, warnings, and recommendations.
1233
+ """
1234
+ validate_accession(accession1)
1235
+ validate_accession(accession2)
1236
+ tracker = _get_tracker()
1237
+ result = tracker.analyze_compatibility(accession1, accession2)
1238
+ return json.dumps(result, indent=2)
1239
+
1240
+
1241
+ # ======================================================================
1242
+ # Tool 15: Log Derived File (Provenance)
1243
+ # ======================================================================
1244
+
1245
+
1246
+ @mcp.tool(annotations=_WRITE_LOCAL, title="Log Derived File")
1247
+ async def encode_log_derived_file(
1248
+ file_path: str,
1249
+ source_accessions: list[str],
1250
+ description: str = "",
1251
+ file_type: str = "",
1252
+ tool_used: str = "",
1253
+ parameters: str = "",
1254
+ ) -> str:
1255
+ """Log a file you've derived from ENCODE data for provenance tracking.
1256
+
1257
+ Use this when you create new files from ENCODE data (e.g., running a pipeline,
1258
+ filtering peaks, merging samples). This creates a provenance record linking
1259
+ your derived file back to the original ENCODE source data.
1260
+
1261
+ WHEN TO USE: Use after creating files from ENCODE data (filtered peaks, merged
1262
+ signals). Creates provenance chain back to source.
1263
+ RELATED TOOLS: encode_get_provenance, encode_download_files
1264
+
1265
+ Args:
1266
+ file_path: Path to the derived file you created
1267
+ source_accessions: List of ENCODE accessions this file was derived from
1268
+ (experiment or file accessions, e.g., ["ENCSR133RZO", "ENCFF635JIA"])
1269
+ description: What this derived file contains
1270
+ file_type: Type of file (e.g., "filtered_peaks", "merged_signal", "differential")
1271
+ tool_used: Tool/software used to create it (e.g., "bedtools intersect", "DESeq2")
1272
+ parameters: Parameters or command used
1273
+
1274
+ Returns:
1275
+ JSON with the provenance record ID.
1276
+ """
1277
+ # Validate each source accession is a valid ENCODE identifier
1278
+ for acc in source_accessions:
1279
+ validate_accession(acc)
1280
+
1281
+ tracker = _get_tracker()
1282
+ row_id = tracker.log_derived_file(
1283
+ file_path=file_path,
1284
+ source_accessions=source_accessions,
1285
+ description=description,
1286
+ file_type=file_type,
1287
+ tool_used=tool_used,
1288
+ parameters=parameters,
1289
+ )
1290
+ return json.dumps(
1291
+ {
1292
+ "success": True,
1293
+ "record_id": row_id,
1294
+ "file_path": file_path,
1295
+ "source_accessions": source_accessions,
1296
+ "message": "Provenance logged. Use encode_get_provenance to view the full chain.",
1297
+ },
1298
+ indent=2,
1299
+ )
1300
+
1301
+
1302
+ # ======================================================================
1303
+ # Tool 16: Get Provenance
1304
+ # ======================================================================
1305
+
1306
+
1307
+ @mcp.tool(annotations=_READONLY_LOCAL, title="Get File Provenance")
1308
+ async def encode_get_provenance(
1309
+ file_path: str | None = None,
1310
+ source_accession: str | None = None,
1311
+ ) -> str:
1312
+ """Get provenance information for derived files.
1313
+
1314
+ Shows the chain from your derived files back to original ENCODE data,
1315
+ including what tools and parameters were used.
1316
+
1317
+ WHEN TO USE: Use to trace a derived file back to original ENCODE data.
1318
+ Shows tools and parameters used.
1319
+ RELATED TOOLS: encode_log_derived_file
1320
+
1321
+ Args:
1322
+ file_path: Get provenance for a specific derived file
1323
+ source_accession: List all files derived from a specific ENCODE accession
1324
+
1325
+ Returns:
1326
+ JSON provenance chain or list of derived files.
1327
+ """
1328
+ tracker = _get_tracker()
1329
+
1330
+ if file_path:
1331
+ chain = tracker.get_provenance_chain(file_path)
1332
+ return json.dumps(chain, indent=2, default=str)
1333
+
1334
+ derived = tracker.get_derived_files(source_accession)
1335
+ return json.dumps(
1336
+ {
1337
+ "derived_files": derived,
1338
+ "count": len(derived),
1339
+ },
1340
+ indent=2,
1341
+ default=str,
1342
+ )
1343
+
1344
+
1345
+ # ======================================================================
1346
+ # Tool 17: Export Tracked Data
1347
+ # ======================================================================
1348
+
1349
+
1350
+ @mcp.tool(annotations=_READONLY_LOCAL, title="Export Tracked Data")
1351
+ async def encode_export_data(
1352
+ format: Literal["csv", "tsv", "json"] = "csv",
1353
+ assay_title: str | None = None,
1354
+ organism: str | None = None,
1355
+ organ: str | None = None,
1356
+ ) -> str:
1357
+ """Export tracked experiments as a table (CSV, TSV, or JSON).
1358
+
1359
+ Creates a tabular export of all tracked experiments with metadata,
1360
+ publication counts, PMIDs, and derived file counts. Useful for loading
1361
+ into Excel, R, pandas, or sharing with collaborators.
1362
+
1363
+ PMIDs in the output can be directly used with PubMed MCP tools for
1364
+ further literature analysis.
1365
+
1366
+ WHEN TO USE: Use to create shareable tables of tracked experiments (CSV, TSV, JSON).
1367
+ Good for manuscripts and reports.
1368
+ RELATED TOOLS: encode_list_tracked, encode_summarize_collection
1369
+
1370
+ Args:
1371
+ format: Output format:
1372
+ - "csv": Comma-separated values (default, for Excel/spreadsheets)
1373
+ - "tsv": Tab-separated values (for R, pandas)
1374
+ - "json": JSON array (for programmatic use)
1375
+ assay_title: Filter by assay type (partial match)
1376
+ organism: Filter by organism (partial match)
1377
+ organ: Filter by organ (partial match)
1378
+
1379
+ Returns:
1380
+ Formatted table data in the requested format.
1381
+ """
1382
+ validate_data_export_format(format)
1383
+ tracker = _get_tracker()
1384
+ result = tracker.export_tracked_data(
1385
+ format=format,
1386
+ assay_title=assay_title,
1387
+ organism=organism,
1388
+ organ=organ,
1389
+ )
1390
+ if not result:
1391
+ return json.dumps({"message": "No tracked experiments found matching filters."})
1392
+ return result
1393
+
1394
+
1395
+ # ======================================================================
1396
+ # Tool 18: Summarize Collection
1397
+ # ======================================================================
1398
+
1399
+
1400
+ @mcp.tool(annotations=_READONLY_LOCAL, title="Summarize Collection")
1401
+ async def encode_summarize_collection(
1402
+ assay_title: str | None = None,
1403
+ organism: str | None = None,
1404
+ organ: str | None = None,
1405
+ ) -> str:
1406
+ """Summarize your tracked experiment collection with grouped statistics.
1407
+
1408
+ Provides an overview of tracked experiments grouped by assay type, target,
1409
+ organism, organ, biosample type, and lab. Shows total counts for publications,
1410
+ derived files, and external references.
1411
+
1412
+ Useful when tracking 10+ experiments and needing a bird's-eye view of your
1413
+ research data collection.
1414
+
1415
+ WHEN TO USE: Use for a bird's-eye view of tracked experiments grouped by assay,
1416
+ target, organ. Best for 10+ tracked experiments.
1417
+ RELATED TOOLS: encode_list_tracked, encode_export_data
1418
+
1419
+ Args:
1420
+ assay_title: Filter by assay type (partial match)
1421
+ organism: Filter by organism (partial match)
1422
+ organ: Filter by organ (partial match)
1423
+
1424
+ Returns:
1425
+ JSON summary with experiment counts grouped by multiple dimensions.
1426
+ """
1427
+ tracker = _get_tracker()
1428
+ summary = tracker.summarize_collection(
1429
+ assay_title=assay_title,
1430
+ organism=organism,
1431
+ organ=organ,
1432
+ )
1433
+ return json.dumps(summary, indent=2)
1434
+
1435
+
1436
+ # ======================================================================
1437
+ # Tool 19: Link External Reference
1438
+ # ======================================================================
1439
+
1440
+
1441
+ @mcp.tool(annotations=_WRITE_LOCAL, title="Link External Reference")
1442
+ async def encode_link_reference(
1443
+ experiment_accession: str,
1444
+ reference_type: Literal["pmid", "doi", "nct_id", "preprint_doi", "geo_accession", "other"],
1445
+ reference_id: str,
1446
+ description: str = "",
1447
+ ) -> str:
1448
+ """Link an external reference to a tracked ENCODE experiment.
1449
+
1450
+ This is the cross-server bridge. Attach PubMed IDs, bioRxiv DOIs,
1451
+ ClinicalTrials.gov NCT IDs, GEO accessions, or any external identifier
1452
+ to your tracked experiments for provenance and cross-referencing.
1453
+
1454
+ After finding a relevant paper with PubMed MCP or a preprint on bioRxiv,
1455
+ link it to the ENCODE experiment for future reference.
1456
+
1457
+ WHEN TO USE: Use to attach external IDs (PMID, DOI, GEO, NCT) to tracked
1458
+ experiments for cross-referencing.
1459
+ RELATED TOOLS: encode_get_references, encode_get_citations
1460
+
1461
+ Args:
1462
+ experiment_accession: ENCODE experiment accession (e.g., "ENCSR133RZO")
1463
+ reference_type: Type of external reference:
1464
+ - "pmid": PubMed ID (e.g., "32728249")
1465
+ - "doi": DOI (e.g., "10.1038/s41586-020-2493-4")
1466
+ - "nct_id": ClinicalTrials.gov ID (e.g., "NCT04567890")
1467
+ - "preprint_doi": bioRxiv/medRxiv DOI
1468
+ - "geo_accession": GEO accession (e.g., "GSE123456")
1469
+ - "other": Any other identifier
1470
+ reference_id: The actual identifier value
1471
+ description: Optional description of why this reference is linked
1472
+
1473
+ Returns:
1474
+ JSON with linking result.
1475
+ """
1476
+ validate_accession(experiment_accession)
1477
+ validate_reference_type(reference_type)
1478
+ tracker = _get_tracker()
1479
+ result = tracker.link_reference(
1480
+ accession=experiment_accession,
1481
+ reference_type=reference_type,
1482
+ reference_id=reference_id,
1483
+ description=description,
1484
+ )
1485
+ return json.dumps(result, indent=2)
1486
+
1487
+
1488
+ # ======================================================================
1489
+ # Tool 20: Get External References
1490
+ # ======================================================================
1491
+
1492
+
1493
+ @mcp.tool(annotations=_READONLY_LOCAL, title="Get Linked References")
1494
+ async def encode_get_references(
1495
+ experiment_accession: str | None = None,
1496
+ reference_type: Literal["pmid", "doi", "nct_id", "preprint_doi", "geo_accession", "other"] | None = None,
1497
+ ) -> str:
1498
+ """Get external references linked to tracked experiments.
1499
+
1500
+ Returns PMIDs, DOIs, NCT IDs, GEO accessions and other identifiers
1501
+ linked to experiments. These identifiers can be directly passed to
1502
+ PubMed, bioRxiv, ClinicalTrials.gov, or other MCP tools.
1503
+
1504
+ WHEN TO USE: Use to retrieve external references linked to experiments.
1505
+ PMIDs can be passed to PubMed MCP tools.
1506
+ RELATED TOOLS: encode_link_reference, encode_get_citations
1507
+
1508
+ Args:
1509
+ experiment_accession: Filter by specific experiment (optional)
1510
+ reference_type: Filter by reference type (optional):
1511
+ "pmid", "doi", "nct_id", "preprint_doi", "geo_accession", "other"
1512
+
1513
+ Returns:
1514
+ JSON with linked external references.
1515
+ """
1516
+ if experiment_accession:
1517
+ validate_accession(experiment_accession)
1518
+ tracker = _get_tracker()
1519
+ refs = tracker.get_references(
1520
+ accession=experiment_accession,
1521
+ reference_type=reference_type,
1522
+ )
1523
+ return json.dumps(
1524
+ {
1525
+ "references": refs,
1526
+ "count": len(refs),
1527
+ },
1528
+ indent=2,
1529
+ default=str,
1530
+ )
1531
+
1532
+
1533
+ # ======================================================================
1534
+ # Entry point
1535
+ # ======================================================================
1536
+
1537
+
1538
+ def main():
1539
+ """Run the MCP server."""
1540
+ mcp.run()
1541
+
1542
+
1543
+ if __name__ == "__main__":
1544
+ main()