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