guohanghui commited on
Commit
470dbf5
·
verified ·
1 Parent(s): dec7ded

Update biopython/mcp_output/mcp_plugin/mcp_service.py

Browse files
biopython/mcp_output/mcp_plugin/mcp_service.py CHANGED
@@ -1,7 +1,6 @@
1
  import os
2
  import sys
3
- import ast
4
- import importlib
5
  from io import StringIO
6
 
7
  # Ensure Biopython source is importable when running the MCP service
@@ -11,485 +10,592 @@ if source_path not in sys.path:
11
 
12
  from fastmcp import FastMCP
13
 
14
- # Lightweight stand-ins for setup.py utilities to avoid running setup()
15
- def biopy_can_import(module_name):
16
- try:
17
- return importlib.import_module(module_name)
18
- except ImportError:
19
- return None
20
-
21
-
22
- def biopy_get_version():
23
- init_path = os.path.join(source_path, "Bio", "__init__.py")
24
- try:
25
- with open(init_path, "r", encoding="utf-8") as handle:
26
- for line in handle:
27
- if line.startswith("__version__ = "):
28
- return ast.literal_eval(line.split("=", 1)[1].strip())
29
- except FileNotFoundError:
30
- return "Unknown"
31
- return "Unknown"
32
-
33
- # ✅ 延迟导入:只在需要时才导入 Scripts 模块
34
- # 这样可以避免在服务启动时触发 setuptools
35
- _scripts_cache = {}
36
-
37
- def lazy_import_script(module_path, attr_name):
38
- """延迟导入 Scripts 模块中的函数或类"""
39
- cache_key = f"{module_path}.{attr_name}"
40
- if cache_key not in _scripts_cache:
41
- try:
42
- module = importlib.import_module(module_path)
43
- _scripts_cache[cache_key] = getattr(module, attr_name)
44
- except Exception as e:
45
- print(f"⚠️ Warning: Could not import {cache_key}: {e}")
46
- _scripts_cache[cache_key] = None
47
- return _scripts_cache[cache_key]
48
-
49
- # Biopython top-level imports (这些是安全的)
50
  from Bio import __version__ as bio_version
51
- from Bio import Entrez, SeqIO, Phylo, AlignIO
52
- from Bio.PDB import PDBParser
53
- from Bio.Align import MultipleSeqAlignment, PairwiseAligner
54
  from Bio.Seq import Seq
 
55
 
56
- # GC function location varies by Biopython version
57
  try:
58
  from Bio.SeqUtils import GC
59
  except ImportError:
60
  try:
61
  from Bio.SeqUtils import gc_fraction as GC
62
  except ImportError:
63
- # Fallback implementation
64
  def GC(seq):
65
  """Calculate GC content percentage"""
66
  seq = str(seq).upper()
67
  gc = seq.count('G') + seq.count('C')
68
  return (gc / len(seq)) * 100 if len(seq) > 0 else 0
69
 
70
- # pairwise2 is deprecated, but keep for compatibility
71
- try:
72
- from Bio import pairwise2
73
- HAS_PAIRWISE2 = True
74
- except (ImportError, AttributeError):
75
- HAS_PAIRWISE2 = False
76
-
77
- mcp = FastMCP("biopython")
78
 
79
- # Configure Entrez email (required by NCBI); allow user override via env
80
  Entrez.email = os.getenv("BIOPYTHON_ENTREZ_EMAIL", "biopython-mcp@huggingface.co")
81
 
82
 
83
- @mcp.tool(name="can_import", description="Check if a module can be imported (from setup.py)")
84
- def can_import_tool(payload: dict):
85
- try:
86
- result = biopy_can_import(**payload)
87
- return {"success": True, "result": str(result), "error": None}
88
- except Exception as e:
89
- return {"success": False, "result": None, "error": str(e)}
90
 
91
- @mcp.tool(name="get_version", description="Biopython package version from setup.py")
92
- def get_version_tool(payload: dict | None = None):
93
- try:
94
- result = biopy_get_version()
95
- return {"success": True, "result": result, "error": None}
96
- except Exception as e:
97
- return {"success": False, "result": None, "error": str(e)}
98
-
99
-
100
- @mcp.tool(name="bio_version", description="Return Bio.__version__ from the Biopython package")
101
- def bio_version_tool(payload: dict | None = None):
102
- try:
103
- return {"success": True, "result": bio_version, "error": None}
104
- except Exception as e:
105
- return {"success": False, "result": None, "error": str(e)}
106
-
107
- @mcp.tool(name="query_pubmed_print_usage", description="Print usage for query_pubmed script")
108
- def print_usage_tool(payload: dict | None = None):
109
  try:
110
- func = lazy_import_script("Scripts.query_pubmed", "print_usage")
111
- if func is None:
112
- return {"success": False, "result": None, "error": "Function not available"}
113
- result = func()
114
- return {"success": True, "result": result, "error": None}
115
  except Exception as e:
116
  return {"success": False, "result": None, "error": str(e)}
117
 
118
- @mcp.tool(name="line_wrap", description="Wrap long strings (from update_ncbi_codon_table.py)")
119
- def line_wrap_tool(payload: dict):
120
- try:
121
- func = lazy_import_script("Scripts.update_ncbi_codon_table", "line_wrap")
122
- if func is None:
123
- return {"success": False, "result": None, "error": "Function not available"}
124
- result = func(**payload)
125
- return {"success": True, "result": result, "error": None}
126
- except Exception as e:
127
- return {"success": False, "result": None, "error": str(e)}
128
 
129
- @mcp.tool(name="scop_main", description="Extract SCOP domain PDB records (Scripts/scop_pdb.py main)")
130
- def scop_main_tool(payload: dict | None = None):
 
 
 
 
 
131
  try:
132
- func = lazy_import_script("Scripts.scop_pdb", "main")
133
- if func is None:
134
- return {"success": False, "result": None, "error": "Function not available"}
135
- result = func()
136
- return {"success": True, "result": result, "error": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  except Exception as e:
138
  return {"success": False, "result": None, "error": str(e)}
139
 
140
- @mcp.tool(name="scop_open_pdb", description="Download/open a PDB file (Scripts/scop_pdb.py open_pdb)")
141
- def open_pdb_tool(payload: dict):
142
- try:
143
- func = lazy_import_script("Scripts.scop_pdb", "open_pdb")
144
- if func is None:
145
- return {"success": False, "result": None, "error": "Function not available"}
146
- result = func(**payload)
147
- return {"success": True, "result": str(result), "error": None}
148
- except Exception as e:
149
- return {"success": False, "result": None, "error": str(e)}
150
 
151
- @mcp.tool(name="scop_usage", description="Print usage for scop_pdb.py")
152
- def usage_tool(payload: dict | None = None):
 
 
 
 
153
  try:
154
- func = lazy_import_script("Scripts.scop_pdb", "usage")
155
- if func is None:
156
- return {"success": False, "result": None, "error": "Function not available"}
157
- result = func()
158
- return {"success": True, "result": result, "error": None}
159
- except Exception as e:
160
- return {"success": False, "result": None, "error": str(e)}
161
-
162
- @mcp.tool(name="notepad", description="Instantiate Scripts/xbbtools/xbb_utils.NotePad")
163
- def notepad_tool(payload: dict):
164
- """NotePad class - pass constructor arguments in payload"""
165
- try:
166
- NotePad = lazy_import_script("Scripts.xbbtools.xbb_utils", "NotePad")
167
- if NotePad is None:
168
- return {"success": False, "result": None, "error": "Class NotePad is not available"}
169
 
170
- # Extract args and kwargs from payload
171
- args = payload.get("args", [])
172
- kwargs = payload.get("kwargs", {})
173
 
174
- instance = NotePad(*args, **kwargs)
175
- return {"success": True, "result": str(instance), "error": None}
 
 
 
 
 
 
176
  except Exception as e:
177
  return {"success": False, "result": None, "error": str(e)}
178
 
179
- @mcp.tool(name="nextorf_help", description="Help text for NextOrf (Scripts/xbbtools/nextorf.py)")
180
- def help_tool(payload: dict | None = None):
181
- try:
182
- func = lazy_import_script("Scripts.xbbtools.nextorf", "help")
183
- if func is None:
184
- return {"success": False, "result": None, "error": "Function not available"}
185
- result = func()
186
- return {"success": True, "result": result, "error": None}
187
- except Exception as e:
188
- return {"success": False, "result": None, "error": str(e)}
189
 
190
- @mcp.tool(name="makeTableX", description="Generate NextOrf lookup table (Scripts/xbbtools/nextorf.py)")
191
- def makeTableX_tool(payload: dict):
192
- try:
193
- func = lazy_import_script("Scripts.xbbtools.nextorf", "makeTableX")
194
- if func is None:
195
- return {"success": False, "result": None, "error": "Function not available"}
196
- result = func(**payload)
197
- return {"success": True, "result": result, "error": None}
198
- except Exception as e:
199
- return {"success": False, "result": None, "error": str(e)}
200
 
201
- @mcp.tool(name="missingtable", description="Instantiate MissingTable (Scripts/xbbtools/nextorf.py)")
202
- def missingtable_tool(payload: dict):
203
- """MissingTable class - pass constructor arguments in payload"""
 
 
 
 
204
  try:
205
- MissingTable = lazy_import_script("Scripts.xbbtools.nextorf", "MissingTable")
206
- if MissingTable is None:
207
- return {"success": False, "result": None, "error": "Class MissingTable is not available"}
208
 
209
- # Extract args and kwargs from payload
210
- args = payload.get("args", [])
211
- kwargs = payload.get("kwargs", {})
212
 
213
- instance = MissingTable(*args, **kwargs)
214
- return {"success": True, "result": str(instance), "error": None}
215
- except Exception as e:
216
- return {"success": False, "result": None, "error": str(e)}
217
-
218
- @mcp.tool(name="nextorf", description="Instantiate NextOrf (Scripts/xbbtools/nextorf.py)")
219
- def nextorf_tool(payload: dict):
220
- """NextOrf class - pass constructor arguments in payload"""
221
- try:
222
- NextOrf = lazy_import_script("Scripts.xbbtools.nextorf", "NextOrf")
223
- if NextOrf is None:
224
- return {"success": False, "result": None, "error": "Class NextOrf is not available"}
225
 
226
- # Extract args and kwargs from payload
227
- args = payload.get("args", [])
228
- kwargs = payload.get("kwargs", {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
- instance = NextOrf(*args, **kwargs)
231
- return {"success": True, "result": str(instance), "error": None}
232
- except Exception as e:
233
- return {"success": False, "result": None, "error": str(e)}
234
-
235
-
236
- # -----------------------
237
- # Core Biopython utilities
238
- # -----------------------
239
-
240
-
241
- @mcp.tool(name="seq_reverse_complement", description="Reverse-complement a DNA/RNA sequence string")
242
- def seq_reverse_complement(payload: dict):
243
- try:
244
- seq = payload.get("sequence", "")
245
- result = str(Seq(seq).reverse_complement())
246
  return {"success": True, "result": result, "error": None}
247
  except Exception as e:
248
  return {"success": False, "result": None, "error": str(e)}
249
 
250
 
251
- @mcp.tool(name="seq_transcribe", description="Transcribe DNA to RNA (replace T->U)")
252
- def seq_transcribe(payload: dict):
 
 
 
 
 
253
  try:
254
- seq = payload.get("sequence", "")
255
- result = str(Seq(seq).transcribe())
256
- return {"success": True, "result": result, "error": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  except Exception as e:
258
  return {"success": False, "result": None, "error": str(e)}
259
 
260
 
261
- @mcp.tool(name="seq_translate", description="Translate DNA/RNA to protein; accepts optional table, to_stop")
262
- def seq_translate(payload: dict):
263
- try:
264
- seq = payload.get("sequence", "")
265
- table = payload.get("table", 1)
266
- to_stop = bool(payload.get("to_stop", False))
267
- cds = bool(payload.get("cds", False))
268
- result = str(Seq(seq).translate(table=table, to_stop=to_stop, cds=cds))
269
- return {"success": True, "result": result, "error": None}
270
- except Exception as e:
271
- return {"success": False, "result": None, "error": str(e)}
272
-
273
 
274
- @mcp.tool(name="gc_content", description="Compute GC percentage of a sequence")
275
- def gc_content(payload: dict):
 
 
 
 
276
  try:
277
- seq = payload.get("sequence", "")
278
- result = GC(seq)
279
- return {"success": True, "result": result, "error": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  except Exception as e:
281
  return {"success": False, "result": None, "error": str(e)}
282
 
283
 
284
- @mcp.tool(name="pairwise_align_globalxx", description="Global alignment (identity scoring) between two sequences")
285
- def pairwise_align_globalxx(payload: dict):
 
 
 
 
286
  try:
287
- seq1 = payload.get("seq1", "")
288
- seq2 = payload.get("seq2", "")
289
- limit = int(payload.get("limit", 3))
290
-
291
- if HAS_PAIRWISE2:
292
- # Use deprecated pairwise2 if available
293
- aligns = pairwise2.align.globalxx(seq1, seq2)[:limit]
294
- result = [
295
- {
296
- "seqA": a.seqA,
297
- "seqB": a.seqB,
298
- "score": a.score,
299
- "start": a.start,
300
- "end": a.end,
301
- }
302
- for a in aligns
303
- ]
304
- else:
305
- # Use modern PairwiseAligner
306
- aligner = PairwiseAligner()
307
- aligner.mode = 'global'
308
- aligner.match_score = 1
309
- aligner.mismatch_score = 0
310
- alignments = list(aligner.align(seq1, seq2))[:limit]
311
- result = [
312
- {
313
- "seqA": str(alignment).split('\n')[0],
314
- "seqB": str(alignment).split('\n')[2] if len(str(alignment).split('\n')) > 2 else "",
315
- "score": alignment.score,
316
- "start": 0,
317
- "end": len(seq1),
318
  }
319
- for alignment in alignments
320
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
 
322
- return {"success": True, "result": result, "error": None}
323
- except Exception as e:
324
- return {"success": False, "result": None, "error": str(e)}
325
-
326
-
327
- @mcp.tool(name="seqio_parse_string", description="Parse FASTA/GenBank data from string; returns list of records")
328
- def seqio_parse_string(payload: dict):
329
- try:
330
- data = payload.get("data", "")
331
- fmt = payload.get("format", "fasta")
332
- handle = StringIO(data)
333
- records = []
334
- for rec in SeqIO.parse(handle, fmt):
335
- records.append({"id": rec.id, "name": rec.name, "description": rec.description, "seq": str(rec.seq)})
336
- return {"success": True, "result": records, "error": None}
337
- except Exception as e:
338
- return {"success": False, "result": None, "error": str(e)}
339
-
340
-
341
- @mcp.tool(name="seqio_convert_file", description="Convert sequence file between formats (e.g., fasta->genbank)")
342
- def seqio_convert_file(payload: dict):
343
- try:
344
- input_path = payload["input_path"]
345
- input_format = payload.get("input_format", "fasta")
346
- output_path = payload["output_path"]
347
- output_format = payload.get("output_format", "genbank")
348
- count = SeqIO.convert(input_path, input_format, output_path, output_format)
349
- return {"success": True, "result": {"converted_records": count, "output_path": output_path}, "error": None}
350
- except Exception as e:
351
- return {"success": False, "result": None, "error": str(e)}
352
-
353
-
354
- @mcp.tool(name="entrez_search", description="NCBI Entrez esearch; provide db, term; optional retmax")
355
- def entrez_search(payload: dict):
356
- try:
357
- db = payload.get("db", "pubmed")
358
- term = payload.get("term", "")
359
- retmax = int(payload.get("retmax", 20))
360
- handle = Entrez.esearch(db=db, term=term, retmax=retmax, usehistory="y")
361
- rec = Entrez.read(handle)
362
- handle.close()
363
  return {
364
  "success": True,
365
- "result": {"ids": rec.get("IdList", []), "count": int(rec.get("Count", 0)), "webenv": rec.get("WebEnv"), "query_key": rec.get("QueryKey")},
366
- "error": None,
 
 
 
367
  }
368
  except Exception as e:
369
  return {"success": False, "result": None, "error": str(e)}
370
 
371
 
372
- @mcp.tool(name="entrez_fetch", description="NCBI Entrez efetch; provide db, id(s), rettype, retmode")
373
- def entrez_fetch(payload: dict):
374
- try:
375
- db = payload.get("db", "pubmed")
376
- ids = payload.get("ids", [])
377
- if isinstance(ids, list):
378
- ids = ",".join(ids)
379
- rettype = payload.get("rettype", "medline")
380
- retmode = payload.get("retmode", "text")
381
- handle = Entrez.efetch(db=db, id=ids, rettype=rettype, retmode=retmode)
382
- data = handle.read()
383
- handle.close()
384
- return {"success": True, "result": data, "error": None}
385
- except Exception as e:
386
- return {"success": False, "result": None, "error": str(e)}
387
-
388
-
389
- @mcp.tool(name="set_entrez_email", description="Override Entrez.email at runtime; required for NCBI")
390
- def set_entrez_email(payload: dict):
391
- try:
392
- email = payload.get("email", "")
393
- if not email:
394
- return {"success": False, "result": None, "error": "email is required"}
395
- Entrez.email = email
396
- return {"success": True, "result": {"email": Entrez.email}, "error": None}
397
- except Exception as e:
398
- return {"success": False, "result": None, "error": str(e)}
399
-
400
-
401
- @mcp.tool(name="phylo_parse_newick", description="Parse Newick string; return leaf names and counts")
402
- def phylo_parse_newick(payload: dict):
403
- try:
404
- newick = payload.get("newick", "")
405
- tree = Phylo.read(StringIO(newick), "newick")
406
- leaves = [term.name for term in tree.get_terminals()]
407
- return {"success": True, "result": {"leaf_count": len(leaves), "leaves": leaves}, "error": None}
408
- except Exception as e:
409
- return {"success": False, "result": None, "error": str(e)}
410
-
411
-
412
- @mcp.tool(name="pdb_summary", description="Parse a PDB file; report chains, residues, atoms")
413
- def pdb_summary(payload: dict):
414
- try:
415
- path = payload.get("path", "")
416
- structure_id = payload.get("structure_id", "structure")
417
- parser = PDBParser(QUIET=True)
418
- structure = parser.get_structure(structure_id, path)
419
- chains = []
420
- for model in structure:
421
- for chain in model:
422
- atom_count = sum(1 for _ in chain.get_atoms())
423
- res_count = sum(1 for _ in chain.get_residues())
424
- chains.append({"id": chain.id, "residues": res_count, "atoms": atom_count})
425
- return {"success": True, "result": {"chains": chains}, "error": None}
426
- except Exception as e:
427
- return {"success": False, "result": None, "error": str(e)}
428
-
429
-
430
- @mcp.tool(name="msa_read_fasta", description="Read a FASTA alignment file; return length and sequences")
431
- def msa_read_fasta(payload: dict):
432
- try:
433
- fmt = payload.get("format", "fasta")
434
- data = payload.get("data", "")
435
- path = payload.get("path", "")
436
- if data:
437
- alignment = AlignIO.read(StringIO(data), fmt)
438
- else:
439
- alignment = AlignIO.read(path, fmt)
440
- seqs = [{"id": rec.id, "seq": str(rec.seq)} for rec in alignment]
441
- aln_len = alignment.get_alignment_length() if isinstance(alignment, MultipleSeqAlignment) else None
442
- return {"success": True, "result": {"alignment_length": aln_len, "sequences": seqs}, "error": None}
443
- except Exception as e:
444
- return {"success": False, "result": None, "error": str(e)}
445
-
446
-
447
- @mcp.tool(name="msa_consensus", description="Compute simple consensus from FASTA alignment file")
448
- def msa_consensus(payload: dict):
449
  try:
450
- fmt = payload.get("format", "fasta")
451
- data = payload.get("data", "")
452
- path = payload.get("path", "")
453
- if data:
454
- alignment = AlignIO.read(StringIO(data), fmt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  else:
456
- alignment = AlignIO.read(path, fmt)
457
- consensus = alignment.column_annotations.get("consensus") if hasattr(alignment, "column_annotations") else None
458
- if consensus is None:
459
- # naive majority rule per column
460
- aln_len = alignment.get_alignment_length()
461
- cols = []
462
- for i in range(aln_len):
463
- counts = {}
464
- for rec in alignment:
465
- c = rec.seq[i]
466
- counts[c] = counts.get(c, 0) + 1
467
- cols.append(max(counts, key=counts.get))
468
- consensus = "".join(cols)
469
- return {"success": True, "result": {"consensus": str(consensus)}, "error": None}
 
 
 
 
 
 
 
 
470
  except Exception as e:
471
  return {"success": False, "result": None, "error": str(e)}
472
 
473
 
474
- @mcp.tool(name="fasta_filter_length", description="Filter FASTA sequences by minimum length; returns kept sequences")
475
- def fasta_filter_length(payload: dict):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  try:
477
- data = payload.get("data", "")
478
- min_len = int(payload.get("min_len", 1))
479
- handle = StringIO(data)
480
- kept = []
481
- for rec in SeqIO.parse(handle, "fasta"):
482
- if len(rec.seq) >= min_len:
483
- kept.append({"id": rec.id, "seq": str(rec.seq), "len": len(rec.seq)})
484
- return {"success": True, "result": {"kept": kept, "count": len(kept)}, "error": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  except Exception as e:
486
- return {"success": False, "result": None, "error": str(e)}
487
-
 
 
 
488
 
489
 
490
  def create_app():
491
  """Create and return FastMCP application instance"""
492
  return mcp
493
 
 
494
  if __name__ == "__main__":
495
  mcp.run(transport="http", host="0.0.0.0", port=8000)
 
1
  import os
2
  import sys
3
+ import re
 
4
  from io import StringIO
5
 
6
  # Ensure Biopython source is importable when running the MCP service
 
10
 
11
  from fastmcp import FastMCP
12
 
13
+ # Biopython imports
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  from Bio import __version__ as bio_version
15
+ from Bio import Entrez, SeqIO
 
 
16
  from Bio.Seq import Seq
17
+ from Bio.Blast import NCBIWWW, NCBIXML
18
 
 
19
  try:
20
  from Bio.SeqUtils import GC
21
  except ImportError:
22
  try:
23
  from Bio.SeqUtils import gc_fraction as GC
24
  except ImportError:
 
25
  def GC(seq):
26
  """Calculate GC content percentage"""
27
  seq = str(seq).upper()
28
  gc = seq.count('G') + seq.count('C')
29
  return (gc / len(seq)) * 100 if len(seq) > 0 else 0
30
 
31
+ mcp = FastMCP("biopython_gene_species_identification")
 
 
 
 
 
 
 
32
 
33
+ # Configure Entrez email (required by NCBI)
34
  Entrez.email = os.getenv("BIOPYTHON_ENTREZ_EMAIL", "biopython-mcp@huggingface.co")
35
 
36
 
37
+ # ==============================================
38
+ # 基础工具 - Basic Utilities
39
+ # ==============================================
 
 
 
 
40
 
41
+ @mcp.tool(name="set_entrez_email", description="Set NCBI Entrez email (required for all NCBI operations)")
42
+ def set_entrez_email(payload: dict):
43
+ """
44
+ 设置 NCBI Entrez 邮箱
45
+ Required fields: email
46
+ """
 
 
 
 
 
 
 
 
 
 
 
 
47
  try:
48
+ email = payload.get("email", "")
49
+ if not email:
50
+ return {"success": False, "result": None, "error": "email is required"}
51
+ Entrez.email = email
52
+ return {"success": True, "result": {"email": Entrez.email}, "error": None}
53
  except Exception as e:
54
  return {"success": False, "result": None, "error": str(e)}
55
 
 
 
 
 
 
 
 
 
 
 
56
 
57
+ @mcp.tool(name="validate_sequence", description="Validate if input is a valid DNA/RNA sequence")
58
+ def validate_sequence(payload: dict):
59
+ """
60
+ 验证序列是否为有效的 DNA/RNA 序列
61
+ Required fields: sequence
62
+ Optional fields: sequence_type (dna/rna/auto)
63
+ """
64
  try:
65
+ sequence = payload.get("sequence", "").strip().upper()
66
+ seq_type = payload.get("sequence_type", "auto").lower()
67
+
68
+ if not sequence:
69
+ return {"success": False, "result": None, "error": "sequence is required"}
70
+
71
+ # Remove whitespace and newlines
72
+ sequence = "".join(sequence.split())
73
+
74
+ # Check for valid DNA/RNA characters (including ambiguity codes)
75
+ dna_chars = set("ATCGN")
76
+ rna_chars = set("AUCGN")
77
+ seq_chars = set(sequence)
78
+
79
+ is_dna = seq_chars.issubset(dna_chars)
80
+ is_rna = seq_chars.issubset(rna_chars)
81
+
82
+ if seq_type == "auto":
83
+ if is_dna:
84
+ detected_type = "dna"
85
+ elif is_rna:
86
+ detected_type = "rna"
87
+ else:
88
+ return {"success": False, "result": None,
89
+ "error": f"Invalid sequence characters found: {seq_chars - dna_chars - rna_chars}"}
90
+ elif seq_type == "dna":
91
+ if not is_dna:
92
+ return {"success": False, "result": None, "error": "Not a valid DNA sequence"}
93
+ detected_type = "dna"
94
+ elif seq_type == "rna":
95
+ if not is_rna:
96
+ return {"success": False, "result": None, "error": "Not a valid RNA sequence"}
97
+ detected_type = "rna"
98
+ else:
99
+ return {"success": False, "result": None, "error": "sequence_type must be dna, rna, or auto"}
100
+
101
+ return {
102
+ "success": True,
103
+ "result": {
104
+ "valid": True,
105
+ "sequence": sequence,
106
+ "length": len(sequence),
107
+ "type": detected_type
108
+ },
109
+ "error": None
110
+ }
111
  except Exception as e:
112
  return {"success": False, "result": None, "error": str(e)}
113
 
 
 
 
 
 
 
 
 
 
 
114
 
115
+ @mcp.tool(name="calculate_gc_content", description="Calculate GC content percentage of a sequence")
116
+ def calculate_gc_content(payload: dict):
117
+ """
118
+ 计算序列的 GC 含量
119
+ Required fields: sequence
120
+ """
121
  try:
122
+ sequence = payload.get("sequence", "")
123
+ if not sequence:
124
+ return {"success": False, "result": None, "error": "sequence is required"}
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
+ gc_pct = GC(sequence)
 
 
127
 
128
+ return {
129
+ "success": True,
130
+ "result": {
131
+ "gc_content": round(gc_pct, 2),
132
+ "sequence_length": len(sequence)
133
+ },
134
+ "error": None
135
+ }
136
  except Exception as e:
137
  return {"success": False, "result": None, "error": str(e)}
138
 
 
 
 
 
 
 
 
 
 
 
139
 
140
+ # ==============================================
141
+ # BLAST 搜索工具 - BLAST Search Tools
142
+ # ==============================================
 
 
 
 
 
 
 
143
 
144
+ @mcp.tool(name="blast_search", description="Search NCBI database using BLAST to find similar sequences")
145
+ def blast_search(payload: dict):
146
+ """
147
+ 使用 BLAST 搜索相似序列
148
+ Required fields: sequence
149
+ Optional fields: database (nt/nr), program (blastn/blastp), hitlist_size (default 20), expect (default 1e-10)
150
+ """
151
  try:
152
+ sequence = str(payload.get("sequence", "")).strip()
153
+ if not sequence:
154
+ return {"success": False, "result": None, "error": "sequence is required"}
155
 
156
+ # Clean sequence
157
+ sequence = "".join(sequence.split()).upper()
 
158
 
159
+ database = payload.get("database", "nt")
160
+ program = payload.get("program", "blastn")
161
+ hitlist_size = int(payload.get("hitlist_size", 20))
162
+ expect = float(payload.get("expect", 1e-10))
 
 
 
 
 
 
 
 
163
 
164
+ print(f"🔍 Submitting BLAST search: {len(sequence)} bp, database={database}, hits={hitlist_size}")
165
+
166
+ # Submit BLAST request
167
+ handle = NCBIWWW.qblast(
168
+ program=program,
169
+ database=database,
170
+ sequence=sequence,
171
+ hitlist_size=hitlist_size,
172
+ expect=expect,
173
+ format_type="XML"
174
+ )
175
+
176
+ blast_record = NCBIXML.read(handle)
177
+ handle.close()
178
+
179
+ print(f"✅ BLAST search completed: {len(blast_record.alignments)} alignments found")
180
+
181
+ hits = []
182
+ for idx, alignment in enumerate(blast_record.alignments[:hitlist_size]):
183
+ if not alignment.hsps:
184
+ continue
185
+
186
+ hsp = alignment.hsps[0]
187
+ align_len = int(hsp.align_length)
188
+ identities = int(hsp.identities)
189
+ identity_pct = (identities / align_len * 100.0) if align_len else 0
190
+
191
+ hits.append({
192
+ "rank": idx + 1,
193
+ "accession": alignment.accession,
194
+ "hit_id": alignment.hit_id,
195
+ "hit_def": alignment.hit_def,
196
+ "length": alignment.length,
197
+ "bit_score": float(hsp.bits),
198
+ "evalue": float(hsp.expect),
199
+ "identity_pct": round(identity_pct, 2),
200
+ "identities": identities,
201
+ "align_length": align_len
202
+ })
203
+
204
+ result = {
205
+ "query_length": len(sequence),
206
+ "database": database,
207
+ "program": program,
208
+ "total_hits": len(hits),
209
+ "hits": hits
210
+ }
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  return {"success": True, "result": result, "error": None}
213
  except Exception as e:
214
  return {"success": False, "result": None, "error": str(e)}
215
 
216
 
217
+ @mcp.tool(name="filter_blast_hits", description="Filter BLAST hits by identity percentage threshold")
218
+ def filter_blast_hits(payload: dict):
219
+ """
220
+ 根据相似度阈值过滤 BLAST 结果
221
+ Required fields: hits (from blast_search result)
222
+ Optional fields: min_identity (default 70), max_hits (default 10)
223
+ """
224
  try:
225
+ hits = payload.get("hits", [])
226
+ min_identity = float(payload.get("min_identity", 70.0))
227
+ max_hits = int(payload.get("max_hits", 10))
228
+
229
+ if not hits:
230
+ return {"success": False, "result": None, "error": "hits list is required"}
231
+
232
+ # Filter by identity percentage
233
+ filtered = [h for h in hits if h.get("identity_pct", 0) >= min_identity]
234
+
235
+ # Limit number of hits
236
+ filtered = filtered[:max_hits]
237
+
238
+ return {
239
+ "success": True,
240
+ "result": {
241
+ "original_count": len(hits),
242
+ "filtered_count": len(filtered),
243
+ "min_identity": min_identity,
244
+ "hits": filtered
245
+ },
246
+ "error": None
247
+ }
248
  except Exception as e:
249
  return {"success": False, "result": None, "error": str(e)}
250
 
251
 
252
+ # ==============================================
253
+ # 物种提取工具 - Species Extraction Tools
254
+ # ==============================================
 
 
 
 
 
 
 
 
 
255
 
256
+ @mcp.tool(name="extract_species_from_blast", description="Extract species names from BLAST hit definitions")
257
+ def extract_species_from_blast(payload: dict):
258
+ """
259
+ 从 BLAST 结果的描述中提取物种名称
260
+ Required fields: hits (from blast_search or filter_blast_hits)
261
+ """
262
  try:
263
+ hits = payload.get("hits", [])
264
+ if not hits:
265
+ return {"success": False, "result": None, "error": "hits list is required"}
266
+
267
+ species_data = []
268
+
269
+ for hit in hits:
270
+ hit_def = hit.get("hit_def", "")
271
+ identity_pct = hit.get("identity_pct", 0)
272
+
273
+ # Extract species name from definition
274
+ # Common patterns: "[Species name]" or "Species name isolate/strain"
275
+ species = None
276
+
277
+ # Try to find text in square brackets first
278
+ bracket_match = re.search(r'\[([^\]]+)\]', hit_def)
279
+ if bracket_match:
280
+ species = bracket_match.group(1)
281
+ else:
282
+ # Try to extract first two words (genus + species)
283
+ words = hit_def.split()
284
+ if len(words) >= 2:
285
+ species = f"{words[0]} {words[1]}"
286
+
287
+ if species:
288
+ species_data.append({
289
+ "species": species,
290
+ "identity_pct": identity_pct,
291
+ "accession": hit.get("accession"),
292
+ "hit_def": hit_def
293
+ })
294
+
295
+ return {
296
+ "success": True,
297
+ "result": {
298
+ "total_species_found": len(species_data),
299
+ "species_data": species_data
300
+ },
301
+ "error": None
302
+ }
303
  except Exception as e:
304
  return {"success": False, "result": None, "error": str(e)}
305
 
306
 
307
+ @mcp.tool(name="aggregate_species_scores", description="Aggregate species identifications by weighted scores")
308
+ def aggregate_species_scores(payload: dict):
309
+ """
310
+ 根据相似度对物种进行加权聚合
311
+ Required fields: species_data (from extract_species_from_blast)
312
+ """
313
  try:
314
+ species_data = payload.get("species_data", [])
315
+ if not species_data:
316
+ return {"success": False, "result": None, "error": "species_data is required"}
317
+
318
+ # Aggregate by species
319
+ species_scores = {}
320
+
321
+ for item in species_data:
322
+ species = item.get("species", "Unknown")
323
+ identity = item.get("identity_pct", 0)
324
+
325
+ if species not in species_scores:
326
+ species_scores[species] = {
327
+ "total_score": 0,
328
+ "count": 0,
329
+ "max_identity": 0,
330
+ "accessions": []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  }
332
+
333
+ # Weight by identity percentage
334
+ species_scores[species]["total_score"] += identity
335
+ species_scores[species]["count"] += 1
336
+ species_scores[species]["max_identity"] = max(
337
+ species_scores[species]["max_identity"],
338
+ identity
339
+ )
340
+ species_scores[species]["accessions"].append(item.get("accession"))
341
+
342
+ # Calculate average scores
343
+ species_list = []
344
+ for species, data in species_scores.items():
345
+ avg_identity = data["total_score"] / data["count"]
346
+ species_list.append({
347
+ "species": species,
348
+ "average_identity": round(avg_identity, 2),
349
+ "max_identity": round(data["max_identity"], 2),
350
+ "hit_count": data["count"],
351
+ "confidence_score": round(avg_identity * data["count"] / 10, 2), # Weighted confidence
352
+ "accessions": data["accessions"][:3] # Top 3 accessions
353
+ })
354
+
355
+ # Sort by confidence score
356
+ species_list.sort(key=lambda x: x["confidence_score"], reverse=True)
357
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  return {
359
  "success": True,
360
+ "result": {
361
+ "total_unique_species": len(species_list),
362
+ "species_rankings": species_list
363
+ },
364
+ "error": None
365
  }
366
  except Exception as e:
367
  return {"success": False, "result": None, "error": str(e)}
368
 
369
 
370
+ @mcp.tool(name="predict_species", description="Predict the most likely species for the input gene")
371
+ def predict_species(payload: dict):
372
+ """
373
+ 预测输入基因最可能的物种
374
+ Required fields: species_rankings (from aggregate_species_scores)
375
+ Optional fields: min_confidence (default 5.0)
376
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  try:
378
+ species_rankings = payload.get("species_rankings", [])
379
+ min_confidence = float(payload.get("min_confidence", 5.0))
380
+
381
+ if not species_rankings:
382
+ return {"success": False, "result": None, "error": "species_rankings is required"}
383
+
384
+ # Filter by minimum confidence
385
+ confident_predictions = [
386
+ sp for sp in species_rankings
387
+ if sp.get("confidence_score", 0) >= min_confidence
388
+ ]
389
+
390
+ if not confident_predictions:
391
+ return {
392
+ "success": True,
393
+ "result": {
394
+ "prediction": "Unknown",
395
+ "confidence": "Low",
396
+ "reason": "No species met minimum confidence threshold",
397
+ "top_candidates": species_rankings[:3]
398
+ },
399
+ "error": None
400
+ }
401
+
402
+ # Get top prediction
403
+ top_prediction = confident_predictions[0]
404
+
405
+ # Determine confidence level
406
+ confidence_score = top_prediction.get("confidence_score", 0)
407
+ if confidence_score >= 50:
408
+ confidence_level = "Very High"
409
+ elif confidence_score >= 20:
410
+ confidence_level = "High"
411
+ elif confidence_score >= 10:
412
+ confidence_level = "Medium"
413
  else:
414
+ confidence_level = "Low"
415
+
416
+ return {
417
+ "success": True,
418
+ "result": {
419
+ "predicted_species": top_prediction.get("species"),
420
+ "confidence_level": confidence_level,
421
+ "confidence_score": confidence_score,
422
+ "average_identity": top_prediction.get("average_identity"),
423
+ "max_identity": top_prediction.get("max_identity"),
424
+ "supporting_hits": top_prediction.get("hit_count"),
425
+ "alternative_species": [
426
+ {
427
+ "species": sp.get("species"),
428
+ "confidence_score": sp.get("confidence_score"),
429
+ "average_identity": sp.get("average_identity")
430
+ }
431
+ for sp in confident_predictions[1:4] # Next 3 candidates
432
+ ]
433
+ },
434
+ "error": None
435
+ }
436
  except Exception as e:
437
  return {"success": False, "result": None, "error": str(e)}
438
 
439
 
440
+ # ==============================================
441
+ # 完整流程工具 - Complete Workflow Tool
442
+ # ==============================================
443
+
444
+ @mcp.tool(name="identify_gene_species_complete",
445
+ description="Complete workflow: input unknown gene sequence, identify species through BLAST search and analysis")
446
+ def identify_gene_species_complete(payload: dict):
447
+ """
448
+ 完整的基因物种鉴定流程
449
+ Required fields: sequence
450
+ Optional fields: min_identity (default 70), max_hits (default 20), database (default nt)
451
+
452
+ This tool orchestrates multiple steps:
453
+ 1. Validate sequence
454
+ 2. Calculate GC content
455
+ 3. BLAST search
456
+ 4. Filter hits
457
+ 5. Extract species
458
+ 6. Aggregate scores
459
+ 7. Predict species
460
+ """
461
  try:
462
+ sequence = payload.get("sequence", "")
463
+ min_identity = float(payload.get("min_identity", 70.0))
464
+ max_hits = int(payload.get("max_hits", 20))
465
+ database = payload.get("database", "nt")
466
+
467
+ workflow_results = {
468
+ "steps": [],
469
+ "errors": []
470
+ }
471
+
472
+ # Step 1: Validate sequence
473
+ print("📋 Step 1: Validating sequence...")
474
+ val_result = validate_sequence({"sequence": sequence})
475
+ workflow_results["steps"].append({
476
+ "step": 1,
477
+ "name": "validate_sequence",
478
+ "result": val_result
479
+ })
480
+
481
+ if not val_result["success"]:
482
+ return {
483
+ "success": False,
484
+ "result": workflow_results,
485
+ "error": "Sequence validation failed"
486
+ }
487
+
488
+ validated_seq = val_result["result"]["sequence"]
489
+ seq_length = val_result["result"]["length"]
490
+
491
+ # Step 2: Calculate GC content
492
+ print("🧬 Step 2: Calculating GC content...")
493
+ gc_result = calculate_gc_content({"sequence": validated_seq})
494
+ workflow_results["steps"].append({
495
+ "step": 2,
496
+ "name": "calculate_gc_content",
497
+ "result": gc_result
498
+ })
499
+
500
+ # Step 3: BLAST search
501
+ print("🔍 Step 3: Running BLAST search...")
502
+ blast_result = blast_search({
503
+ "sequence": validated_seq,
504
+ "database": database,
505
+ "hitlist_size": max_hits
506
+ })
507
+ workflow_results["steps"].append({
508
+ "step": 3,
509
+ "name": "blast_search",
510
+ "result": blast_result
511
+ })
512
+
513
+ if not blast_result["success"]:
514
+ return {
515
+ "success": False,
516
+ "result": workflow_results,
517
+ "error": "BLAST search failed"
518
+ }
519
+
520
+ hits = blast_result["result"]["hits"]
521
+
522
+ # Step 4: Filter hits
523
+ print("🔬 Step 4: Filtering BLAST hits...")
524
+ filter_result = filter_blast_hits({
525
+ "hits": hits,
526
+ "min_identity": min_identity,
527
+ "max_hits": 10
528
+ })
529
+ workflow_results["steps"].append({
530
+ "step": 4,
531
+ "name": "filter_blast_hits",
532
+ "result": filter_result
533
+ })
534
+
535
+ filtered_hits = filter_result["result"]["hits"]
536
+
537
+ # Step 5: Extract species
538
+ print("🌍 Step 5: Extracting species information...")
539
+ species_result = extract_species_from_blast({"hits": filtered_hits})
540
+ workflow_results["steps"].append({
541
+ "step": 5,
542
+ "name": "extract_species_from_blast",
543
+ "result": species_result
544
+ })
545
+
546
+ species_data = species_result["result"]["species_data"]
547
+
548
+ # Step 6: Aggregate scores
549
+ print("📊 Step 6: Aggregating species scores...")
550
+ agg_result = aggregate_species_scores({"species_data": species_data})
551
+ workflow_results["steps"].append({
552
+ "step": 6,
553
+ "name": "aggregate_species_scores",
554
+ "result": agg_result
555
+ })
556
+
557
+ species_rankings = agg_result["result"]["species_rankings"]
558
+
559
+ # Step 7: Predict species
560
+ print("🎯 Step 7: Predicting species...")
561
+ pred_result = predict_species({"species_rankings": species_rankings})
562
+ workflow_results["steps"].append({
563
+ "step": 7,
564
+ "name": "predict_species",
565
+ "result": pred_result
566
+ })
567
+
568
+ # Compile final result
569
+ final_result = {
570
+ "input_sequence_length": seq_length,
571
+ "gc_content": gc_result["result"]["gc_content"] if gc_result["success"] else None,
572
+ "total_blast_hits": len(hits),
573
+ "filtered_hits": len(filtered_hits),
574
+ "unique_species_found": len(species_rankings),
575
+ "prediction": pred_result["result"] if pred_result["success"] else None,
576
+ "workflow": workflow_results
577
+ }
578
+
579
+ print("✅ Complete workflow finished successfully!")
580
+
581
+ return {
582
+ "success": True,
583
+ "result": final_result,
584
+ "error": None
585
+ }
586
+
587
  except Exception as e:
588
+ return {
589
+ "success": False,
590
+ "result": None,
591
+ "error": f"Workflow error: {str(e)}"
592
+ }
593
 
594
 
595
  def create_app():
596
  """Create and return FastMCP application instance"""
597
  return mcp
598
 
599
+
600
  if __name__ == "__main__":
601
  mcp.run(transport="http", host="0.0.0.0", port=8000)