amr-mcp 0.1.0__tar.gz

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,15 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python-version: ["3.10", "3.12", "3.14"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: ${{ matrix.python-version }}
14
+ - run: pip install -e ".[dev]"
15
+ - run: pytest -m "not network"
@@ -0,0 +1,80 @@
1
+ # Publishes to PyPI and the MCP Registry when a version tag (v0.1.0, v0.2.0...) is pushed.
2
+ # No secrets needed: both use GitHub OIDC (PyPI "trusted publishing").
3
+ name: Release
4
+ on:
5
+ push:
6
+ tags: ["v*"]
7
+
8
+ env:
9
+ MCP_PUBLISHER_VERSION: v1.8.1
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v5
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+ - name: Check tag matches pyproject.toml and server.json
20
+ run: |
21
+ tag="${GITHUB_REF_NAME#v}"
22
+ py=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
23
+ srv=$(jq -r .version server.json)
24
+ pkg=$(jq -r '.packages[0].version' server.json)
25
+ echo "tag=$tag pyproject=$py server.json=$srv/$pkg"
26
+ [ "$tag" = "$py" ] && [ "$tag" = "$srv" ] && [ "$tag" = "$pkg" ]
27
+ - run: pip install -e ".[dev]" build
28
+ - run: pytest -m "not network"
29
+ - run: python -m build
30
+ - uses: actions/upload-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+
35
+ pypi:
36
+ needs: build
37
+ runs-on: ubuntu-latest
38
+ environment: pypi
39
+ permissions:
40
+ id-token: write
41
+ steps:
42
+ - uses: actions/download-artifact@v4
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+ - uses: pypa/gh-action-pypi-publish@release/v1
47
+
48
+ mcp-registry:
49
+ needs: pypi
50
+ runs-on: ubuntu-latest
51
+ permissions:
52
+ id-token: write
53
+ contents: read
54
+ steps:
55
+ - uses: actions/checkout@v5
56
+ - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
57
+ - name: Install mcp-publisher (signature-verified)
58
+ run: |
59
+ set -euo pipefail
60
+ os="$(uname -s | tr '[:upper:]' '[:lower:]')"
61
+ arch="$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')"
62
+ archive="mcp-publisher_${os}_${arch}.tar.gz"
63
+ base_url="https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}"
64
+ curl --fail --location --silent --show-error --output "$archive" "$base_url/$archive"
65
+ curl --fail --location --silent --show-error --output "$archive.sigstore.json" "$base_url/$archive.sigstore.json"
66
+ cosign verify-blob \
67
+ --bundle "$archive.sigstore.json" \
68
+ --certificate-identity "https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/${MCP_PUBLISHER_VERSION}" \
69
+ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
70
+ "$archive"
71
+ tar xzf "$archive" mcp-publisher
72
+ rm -f "$archive" "$archive.sigstore.json"
73
+ - run: ./mcp-publisher login github-oidc
74
+ - name: Publish (retries while PyPI propagates the new release)
75
+ run: |
76
+ for i in 1 2 3 4 5; do
77
+ ./mcp-publisher publish && exit 0
78
+ sleep 30
79
+ done
80
+ exit 1
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
amr_mcp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pablo Estepa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
amr_mcp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: amr-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for antimicrobial resistance (AMR) data: NCBI Reference Gene Catalog, NCBI E-utilities and UniProt
5
+ Project-URL: Homepage, https://github.com/pabloestepaa/amr-mcp
6
+ Project-URL: Issues, https://github.com/pabloestepaa/amr-mcp/issues
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: amr,antimicrobial-resistance,bioinformatics,mcp,ncbi,uniprot
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: mcp<3,>=2.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
18
+ Requires-Dist: pytest>=8; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # amr-mcp
22
+
23
+ <!-- mcp-name: io.github.pabloestepaa/amr-mcp -->
24
+
25
+ [![CI](https://github.com/pabloestepaa/amr-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/pabloestepaa/amr-mcp/actions/workflows/ci.yml)
26
+ [![PyPI](https://img.shields.io/pypi/v/amr-mcp)](https://pypi.org/project/amr-mcp/)
27
+
28
+ An [MCP](https://modelcontextprotocol.io) server that lets AI assistants (Claude, Codex, Cursor…) query
29
+ **antimicrobial resistance (AMR)** data from public, free sources:
30
+
31
+ - **NCBI Reference Gene Catalog**, the curated database behind
32
+ [AMRFinderPlus](https://github.com/ncbi/amr): ~11,500 AMR genes, point mutations, stress and virulence genes
33
+ - **NCBI E-utilities**: protein and nucleotide sequences, PubMed references
34
+ - **UniProtKB**: protein function, keywords and cross-references
35
+ - **AMRFinderPlus** (optional, if installed locally): screen your own genomes
36
+
37
+ All data sources are public domain or freely accessible; no API keys are required.
38
+
39
+ ## Example questions
40
+
41
+ > Which carbapenemases are reported in *Klebsiella pneumoniae*, and what is the original paper for KPC-2?
42
+
43
+ > Give me the protein sequence of mcr-1.1 and its UniProt function annotation.
44
+
45
+ > Run AMRFinderPlus on `~/data/isolate_17.fasta` as *Escherichia* and explain the hits by drug class.
46
+
47
+ ## Tools
48
+
49
+ | Tool | What it does |
50
+ |---|---|
51
+ | `search_amr_genes` | Search by text, drug class, subclass, organism or element type |
52
+ | `get_amr_gene` | Exact record for an allele (`blaKPC-2`) or summary of a family (`blaKPC`) |
53
+ | `list_drug_classes` | Drug classes and how many catalog entries each has |
54
+ | `catalog_info` | Catalog version and size |
55
+ | `fetch_sequence` | Protein or nucleotide FASTA from NCBI by accession |
56
+ | `get_pubmed_references` | Title, journal, authors and DOI for PubMed IDs |
57
+ | `search_uniprot` / `get_uniprot_entry` | UniProtKB search and entry details |
58
+ | `run_amrfinder` | Run a local AMRFinderPlus install on a FASTA file |
59
+
60
+ ## Installation
61
+
62
+ Run it with [uv](https://docs.astral.sh/uv/) (no install step needed), or `pip install amr-mcp`.
63
+
64
+ ### Claude Code
65
+
66
+ ```bash
67
+ claude mcp add amr -- uvx amr-mcp
68
+ ```
69
+
70
+ ### Claude Desktop
71
+
72
+ Add to `claude_desktop_config.json`:
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "amr": { "command": "uvx", "args": ["amr-mcp"] }
78
+ }
79
+ }
80
+ ```
81
+
82
+ ## Configuration
83
+
84
+ | Variable | Purpose |
85
+ |---|---|
86
+ | `NCBI_API_KEY` | Optional, free. Raises the NCBI rate limit from 3 to 10 requests/s |
87
+ | `NCBI_EMAIL` | Optional. Contact e-mail NCBI asks tools to send |
88
+ | `AMR_MCP_CACHE_DIR` | Where the catalog is cached (default `~/.cache/amr-mcp`, refreshed every 30 days) |
89
+
90
+ To enable `run_amrfinder`: `conda install -c bioconda ncbi-amrfinderplus && amrfinder -u`.
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
96
+ .venv/bin/pytest -m "not network" # offline tests
97
+ .venv/bin/pytest # also hits the live NCBI/UniProt APIs
98
+ ```
99
+
100
+ ## Data licensing
101
+
102
+ The NCBI Reference Gene Catalog and NCBI sequence data are US government works in the public domain.
103
+ UniProt data is available under CC BY 4.0. Please cite the original sources when you publish results:
104
+
105
+ - Feldgarden M, et al. *AMRFinderPlus and the Reference Gene Catalog facilitate examination of the
106
+ genomic links among antimicrobial resistance, stress response, and virulence.* Sci Rep. 2021.
107
+ - The UniProt Consortium. *UniProt: the Universal Protein Knowledgebase.* Nucleic Acids Res.
108
+
109
+ ## License
110
+
111
+ MIT
112
+
113
+ ---
114
+
115
+ Developed with AI assistance (Claude Code); all code reviewed and tested by the author.
@@ -0,0 +1,95 @@
1
+ # amr-mcp
2
+
3
+ <!-- mcp-name: io.github.pabloestepaa/amr-mcp -->
4
+
5
+ [![CI](https://github.com/pabloestepaa/amr-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/pabloestepaa/amr-mcp/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/amr-mcp)](https://pypi.org/project/amr-mcp/)
7
+
8
+ An [MCP](https://modelcontextprotocol.io) server that lets AI assistants (Claude, Codex, Cursor…) query
9
+ **antimicrobial resistance (AMR)** data from public, free sources:
10
+
11
+ - **NCBI Reference Gene Catalog**, the curated database behind
12
+ [AMRFinderPlus](https://github.com/ncbi/amr): ~11,500 AMR genes, point mutations, stress and virulence genes
13
+ - **NCBI E-utilities**: protein and nucleotide sequences, PubMed references
14
+ - **UniProtKB**: protein function, keywords and cross-references
15
+ - **AMRFinderPlus** (optional, if installed locally): screen your own genomes
16
+
17
+ All data sources are public domain or freely accessible; no API keys are required.
18
+
19
+ ## Example questions
20
+
21
+ > Which carbapenemases are reported in *Klebsiella pneumoniae*, and what is the original paper for KPC-2?
22
+
23
+ > Give me the protein sequence of mcr-1.1 and its UniProt function annotation.
24
+
25
+ > Run AMRFinderPlus on `~/data/isolate_17.fasta` as *Escherichia* and explain the hits by drug class.
26
+
27
+ ## Tools
28
+
29
+ | Tool | What it does |
30
+ |---|---|
31
+ | `search_amr_genes` | Search by text, drug class, subclass, organism or element type |
32
+ | `get_amr_gene` | Exact record for an allele (`blaKPC-2`) or summary of a family (`blaKPC`) |
33
+ | `list_drug_classes` | Drug classes and how many catalog entries each has |
34
+ | `catalog_info` | Catalog version and size |
35
+ | `fetch_sequence` | Protein or nucleotide FASTA from NCBI by accession |
36
+ | `get_pubmed_references` | Title, journal, authors and DOI for PubMed IDs |
37
+ | `search_uniprot` / `get_uniprot_entry` | UniProtKB search and entry details |
38
+ | `run_amrfinder` | Run a local AMRFinderPlus install on a FASTA file |
39
+
40
+ ## Installation
41
+
42
+ Run it with [uv](https://docs.astral.sh/uv/) (no install step needed), or `pip install amr-mcp`.
43
+
44
+ ### Claude Code
45
+
46
+ ```bash
47
+ claude mcp add amr -- uvx amr-mcp
48
+ ```
49
+
50
+ ### Claude Desktop
51
+
52
+ Add to `claude_desktop_config.json`:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "amr": { "command": "uvx", "args": ["amr-mcp"] }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ | Variable | Purpose |
65
+ |---|---|
66
+ | `NCBI_API_KEY` | Optional, free. Raises the NCBI rate limit from 3 to 10 requests/s |
67
+ | `NCBI_EMAIL` | Optional. Contact e-mail NCBI asks tools to send |
68
+ | `AMR_MCP_CACHE_DIR` | Where the catalog is cached (default `~/.cache/amr-mcp`, refreshed every 30 days) |
69
+
70
+ To enable `run_amrfinder`: `conda install -c bioconda ncbi-amrfinderplus && amrfinder -u`.
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
76
+ .venv/bin/pytest -m "not network" # offline tests
77
+ .venv/bin/pytest # also hits the live NCBI/UniProt APIs
78
+ ```
79
+
80
+ ## Data licensing
81
+
82
+ The NCBI Reference Gene Catalog and NCBI sequence data are US government works in the public domain.
83
+ UniProt data is available under CC BY 4.0. Please cite the original sources when you publish results:
84
+
85
+ - Feldgarden M, et al. *AMRFinderPlus and the Reference Gene Catalog facilitate examination of the
86
+ genomic links among antimicrobial resistance, stress response, and virulence.* Sci Rep. 2021.
87
+ - The UniProt Consortium. *UniProt: the Universal Protein Knowledgebase.* Nucleic Acids Res.
88
+
89
+ ## License
90
+
91
+ MIT
92
+
93
+ ---
94
+
95
+ Developed with AI assistance (Claude Code); all code reviewed and tested by the author.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "amr-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for antimicrobial resistance (AMR) data: NCBI Reference Gene Catalog, NCBI E-utilities and UniProt"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ keywords = ["mcp", "bioinformatics", "antimicrobial-resistance", "amr", "ncbi", "uniprot"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
16
+ "Intended Audience :: Science/Research",
17
+ ]
18
+ dependencies = ["mcp>=2.0,<3", "httpx>=0.27"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/pabloestepaa/amr-mcp"
22
+ Issues = "https://github.com/pabloestepaa/amr-mcp/issues"
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
26
+
27
+ [project.scripts]
28
+ amr-mcp = "amr_mcp.server:main"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/amr_mcp"]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+ asyncio_default_fixture_loop_scope = "function"
36
+ markers = ["network: hits live NCBI/UniProt APIs (deselect with -m 'not network')"]
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pabloestepaa/amr-mcp",
4
+ "title": "AMR (antimicrobial resistance)",
5
+ "description": "Antimicrobial resistance genes, sequences and literature from NCBI and UniProt",
6
+ "repository": {
7
+ "url": "https://github.com/pabloestepaa/amr-mcp",
8
+ "source": "github"
9
+ },
10
+ "version": "0.1.0",
11
+ "packages": [
12
+ {
13
+ "registryType": "pypi",
14
+ "identifier": "amr-mcp",
15
+ "version": "0.1.0",
16
+ "transport": {
17
+ "type": "stdio"
18
+ },
19
+ "environmentVariables": [
20
+ {
21
+ "name": "NCBI_API_KEY",
22
+ "description": "Optional free NCBI API key; raises the rate limit from 3 to 10 requests/s",
23
+ "isRequired": false,
24
+ "isSecret": true
25
+ },
26
+ {
27
+ "name": "NCBI_EMAIL",
28
+ "description": "Optional contact e-mail sent to NCBI with each request",
29
+ "isRequired": false
30
+ }
31
+ ]
32
+ }
33
+ ]
34
+ }
@@ -0,0 +1,3 @@
1
+ """amr-mcp: MCP server for antimicrobial resistance data from NCBI and UniProt."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,47 @@
1
+ """Optional wrapper around a locally installed AMRFinderPlus (``amrfinder``) binary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import csv
7
+ import io
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ TIMEOUT_SECONDS = 1800
12
+
13
+
14
+ def available() -> bool:
15
+ return shutil.which("amrfinder") is not None
16
+
17
+
18
+ async def run(
19
+ fasta_path: str, sequence_type: str, organism: str | None, threads: int = 4
20
+ ) -> dict:
21
+ if not available():
22
+ raise RuntimeError(
23
+ "AMRFinderPlus is not installed. Install it for free with "
24
+ "`conda install -c bioconda ncbi-amrfinderplus` and run `amrfinder -u` once."
25
+ )
26
+ path = Path(fasta_path).expanduser().resolve()
27
+ if not path.is_file():
28
+ raise ValueError(f"FASTA file not found: {path}")
29
+
30
+ flag = {"nucleotide": "-n", "protein": "-p"}[sequence_type]
31
+ args = ["amrfinder", flag, str(path), "--plus", "--threads", str(threads)]
32
+ if organism:
33
+ args += ["-O", organism.replace(" ", "_")]
34
+
35
+ proc = await asyncio.create_subprocess_exec(
36
+ *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
37
+ )
38
+ try:
39
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), TIMEOUT_SECONDS)
40
+ except TimeoutError:
41
+ proc.kill()
42
+ raise RuntimeError(f"amrfinder timed out after {TIMEOUT_SECONDS}s") from None
43
+ if proc.returncode != 0:
44
+ raise RuntimeError(f"amrfinder failed: {stderr.decode(errors='replace')[-2000:]}")
45
+
46
+ hits = list(csv.DictReader(io.StringIO(stdout.decode()), delimiter="\t"))
47
+ return {"file": str(path), "organism": organism, "hit_count": len(hits), "hits": hits}
@@ -0,0 +1,176 @@
1
+ """NCBI Reference Gene Catalog (the AMRFinderPlus database): download, cache and search.
2
+
3
+ The catalog is a public-domain TSV published by NCBI. It is downloaded once and
4
+ cached on disk; it is refreshed when older than ``max_age_days``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ import os
11
+ import time
12
+ from collections import Counter
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+
18
+ CATALOG_URL = (
19
+ "https://ftp.ncbi.nlm.nih.gov/pathogen/Antimicrobial_resistance/"
20
+ "AMRFinderPlus/database/latest/ReferenceGeneCatalog.txt"
21
+ )
22
+
23
+ # Columns exposed to clients. The raw file has more (coordinates, strands...)
24
+ # that are rarely useful to an LLM and only bloat responses.
25
+ PUBLIC_FIELDS = (
26
+ "allele",
27
+ "gene_family",
28
+ "product_name",
29
+ "scope",
30
+ "type",
31
+ "subtype",
32
+ "class",
33
+ "subclass",
34
+ "whitelisted_taxa",
35
+ "refseq_protein_accession",
36
+ "refseq_nucleotide_accession",
37
+ "genbank_protein_accession",
38
+ "genbank_nucleotide_accession",
39
+ "pubmed_reference",
40
+ "synonyms",
41
+ )
42
+
43
+
44
+ def default_cache_dir() -> Path:
45
+ if env := os.environ.get("AMR_MCP_CACHE_DIR"):
46
+ return Path(env)
47
+ return Path.home() / ".cache" / "amr-mcp"
48
+
49
+
50
+ @dataclass
51
+ class Catalog:
52
+ rows: list[dict[str, str]]
53
+
54
+ @classmethod
55
+ def from_file(cls, path: Path) -> Catalog:
56
+ with path.open(newline="", encoding="utf-8") as fh:
57
+ rows = [dict(r) for r in csv.DictReader(fh, delimiter="\t")]
58
+ return cls(rows)
59
+
60
+ @property
61
+ def db_version(self) -> str:
62
+ return self.rows[0].get("db_version", "unknown") if self.rows else "empty"
63
+
64
+ def info(self) -> dict:
65
+ return {
66
+ "source": CATALOG_URL,
67
+ "db_version": self.db_version,
68
+ "total_entries": len(self.rows),
69
+ "by_type": dict(Counter(r["type"] for r in self.rows)),
70
+ "by_scope": dict(Counter(r["scope"] for r in self.rows)),
71
+ }
72
+
73
+ def drug_classes(self) -> list[dict]:
74
+ counts = Counter(r["class"] for r in self.rows if r["class"])
75
+ return [{"class": c, "entries": n} for c, n in counts.most_common()]
76
+
77
+ def search(
78
+ self,
79
+ query: str | None = None,
80
+ drug_class: str | None = None,
81
+ subclass: str | None = None,
82
+ organism: str | None = None,
83
+ element_type: str | None = None,
84
+ limit: int = 25,
85
+ ) -> dict:
86
+ q = query.lower() if query else None
87
+ organism_key = organism.replace(" ", "_").lower() if organism else None
88
+
89
+ def matches(r: dict[str, str]) -> bool:
90
+ if q and not any(
91
+ q in r[f].lower() for f in ("allele", "gene_family", "product_name", "synonyms")
92
+ ):
93
+ return False
94
+ if drug_class and drug_class.lower() not in r["class"].lower():
95
+ return False
96
+ if subclass and subclass.lower() not in r["subclass"].lower():
97
+ return False
98
+ if element_type and element_type.upper() != r["type"]:
99
+ return False
100
+ if organism_key:
101
+ # Entries with no whitelisted taxa apply to any organism.
102
+ allowed = _taxa(r["whitelisted_taxa"])
103
+ if allowed and not any(organism_key.startswith(t) for t in allowed):
104
+ return False
105
+ if any(organism_key.startswith(t) for t in _taxa(r["blacklisted_taxa"])):
106
+ return False
107
+ return True
108
+
109
+ hits = [r for r in self.rows if matches(r)]
110
+ return {
111
+ "total_matches": len(hits),
112
+ "returned": min(len(hits), limit),
113
+ "results": [_public(r) for r in hits[:limit]],
114
+ }
115
+
116
+ def get(self, name: str) -> dict:
117
+ """Exact lookup by allele (e.g. blaKPC-2) or gene family (e.g. blaKPC)."""
118
+ key = name.lower()
119
+ alleles = [r for r in self.rows if r["allele"].lower() == key]
120
+ if alleles:
121
+ return {"match": "allele", "entries": [_public(r) for r in alleles]}
122
+ family = [r for r in self.rows if r["gene_family"].lower() == key]
123
+ if not family:
124
+ return {"match": None, "entries": []}
125
+ named = [r["allele"] for r in family if r["allele"]]
126
+ return {
127
+ "match": "gene_family",
128
+ "gene_family": family[0]["gene_family"],
129
+ "allele_count": len(named),
130
+ "alleles": named[:200],
131
+ "classes": sorted({r["class"] for r in family if r["class"]}),
132
+ "subclasses": sorted({r["subclass"] for r in family if r["subclass"]}),
133
+ "example_entry": _public(family[0]),
134
+ }
135
+
136
+
137
+ def _taxa(field: str) -> list[str]:
138
+ # Taxa are genus or Genus_species names, comma-separated when there are several.
139
+ return [t.strip().lower() for t in field.split(",") if t.strip()]
140
+
141
+
142
+ def _public(row: dict[str, str]) -> dict[str, str]:
143
+ return {f: row[f] for f in PUBLIC_FIELDS if row.get(f)}
144
+
145
+
146
+ async def load_catalog(
147
+ cache_dir: Path | None = None,
148
+ max_age_days: float = 30,
149
+ client: httpx.AsyncClient | None = None,
150
+ ) -> Catalog:
151
+ cache_dir = cache_dir or default_cache_dir()
152
+ path = cache_dir / "ReferenceGeneCatalog.txt"
153
+ stale = not path.exists() or (time.time() - path.stat().st_mtime) > max_age_days * 86400
154
+ if stale:
155
+ try:
156
+ await _download(path, client)
157
+ except httpx.HTTPError:
158
+ # Offline or NCBI down: fall back to the stale copy if we have one.
159
+ if not path.exists():
160
+ raise
161
+ return Catalog.from_file(path)
162
+
163
+
164
+ async def _download(path: Path, client: httpx.AsyncClient | None) -> None:
165
+ path.parent.mkdir(parents=True, exist_ok=True)
166
+ tmp = path.with_suffix(".part")
167
+ owns_client = client is None
168
+ client = client or httpx.AsyncClient(timeout=60, follow_redirects=True)
169
+ try:
170
+ resp = await client.get(CATALOG_URL)
171
+ resp.raise_for_status()
172
+ tmp.write_bytes(resp.content)
173
+ tmp.replace(path)
174
+ finally:
175
+ if owns_client:
176
+ await client.aclose()
@@ -0,0 +1,99 @@
1
+ """Minimal NCBI E-utilities client (efetch / esummary).
2
+
3
+ Without an API key NCBI allows 3 requests/second; with one (free, set
4
+ ``NCBI_API_KEY``) it allows 10. Requests are spaced accordingly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ import re
12
+ import time
13
+
14
+ import httpx
15
+
16
+ EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
17
+ TOOL_NAME = "amr-mcp"
18
+
19
+ # RefSeq / GenBank / WGS accessions, optionally versioned (WP_004199234.1, NG_049253.1, AY034847).
20
+ ACCESSION_RE = re.compile(r"^[A-Z]{1,6}_?[A-Z0-9]*\d+(\.\d+)?$")
21
+ MAX_SEQUENCE_CHARS = 200_000
22
+
23
+
24
+ class NCBIClient:
25
+ def __init__(self, client: httpx.AsyncClient | None = None) -> None:
26
+ self._client = client or httpx.AsyncClient(timeout=30, follow_redirects=True)
27
+ self._api_key = os.environ.get("NCBI_API_KEY")
28
+ self._email = os.environ.get("NCBI_EMAIL")
29
+ self._min_interval = 0.11 if self._api_key else 0.34
30
+ self._lock = asyncio.Lock()
31
+ self._last = 0.0
32
+
33
+ async def aclose(self) -> None:
34
+ await self._client.aclose()
35
+
36
+ async def _get(self, endpoint: str, params: dict[str, str]) -> httpx.Response:
37
+ params = {**params, "tool": TOOL_NAME}
38
+ if self._api_key:
39
+ params["api_key"] = self._api_key
40
+ if self._email:
41
+ params["email"] = self._email
42
+ async with self._lock:
43
+ wait = self._min_interval - (time.monotonic() - self._last)
44
+ if wait > 0:
45
+ await asyncio.sleep(wait)
46
+ self._last = time.monotonic()
47
+ resp = await self._client.get(f"{EUTILS}/{endpoint}", params=params)
48
+ resp.raise_for_status()
49
+ return resp
50
+
51
+ async def fetch_fasta(self, accession: str, kind: str) -> dict:
52
+ accession = accession.strip().upper()
53
+ if not ACCESSION_RE.match(accession):
54
+ raise ValueError(f"'{accession}' does not look like an NCBI accession")
55
+ db = {"protein": "protein", "nucleotide": "nuccore"}[kind]
56
+ resp = await self._get(
57
+ "efetch.fcgi", {"db": db, "id": accession, "rettype": "fasta", "retmode": "text"}
58
+ )
59
+ fasta = resp.text.strip()
60
+ if not fasta.startswith(">"):
61
+ raise ValueError(f"NCBI returned no FASTA for {accession} in db={db}")
62
+ header, _, body = fasta.partition("\n")
63
+ seq = body.replace("\n", "")
64
+ truncated = len(seq) > MAX_SEQUENCE_CHARS
65
+ return {
66
+ "accession": accession,
67
+ "db": db,
68
+ "header": header[1:],
69
+ "length": len(seq),
70
+ "truncated": truncated,
71
+ "sequence": seq[:MAX_SEQUENCE_CHARS],
72
+ }
73
+
74
+ async def pubmed_summaries(self, pmids: list[str]) -> list[dict]:
75
+ ids = [p.strip() for p in pmids if p.strip().isdigit()]
76
+ if not ids:
77
+ raise ValueError("Provide numeric PubMed IDs")
78
+ resp = await self._get(
79
+ "esummary.fcgi", {"db": "pubmed", "id": ",".join(ids[:50]), "retmode": "json"}
80
+ )
81
+ result = resp.json().get("result", {})
82
+ out = []
83
+ for pmid in result.get("uids", []):
84
+ doc = result[pmid]
85
+ out.append(
86
+ {
87
+ "pmid": pmid,
88
+ "title": doc.get("title"),
89
+ "journal": doc.get("fulljournalname") or doc.get("source"),
90
+ "pubdate": doc.get("pubdate"),
91
+ "authors": [a["name"] for a in doc.get("authors", [])][:6],
92
+ "doi": next(
93
+ (i["value"] for i in doc.get("articleids", []) if i["idtype"] == "doi"),
94
+ None,
95
+ ),
96
+ "url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
97
+ }
98
+ )
99
+ return out
@@ -0,0 +1,172 @@
1
+ """MCP server exposing antimicrobial-resistance data from public NCBI and UniProt sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import functools
7
+ from typing import Annotated, Any, Literal
8
+
9
+ import httpx
10
+
11
+ from mcp.server.mcpserver import MCPServer
12
+ from mcp.server.mcpserver.exceptions import ToolError
13
+ from mcp_types import ToolAnnotations
14
+ from pydantic import Field
15
+
16
+ from . import amrfinder
17
+ from .catalog import Catalog, load_catalog
18
+ from .ncbi import NCBIClient
19
+ from .uniprot import UniProtClient
20
+
21
+ INSTRUCTIONS = """\
22
+ Tools for antimicrobial resistance (AMR) research backed by public databases:
23
+ the NCBI Reference Gene Catalog (the AMRFinderPlus database), NCBI E-utilities and UniProt.
24
+ Typical flow: search_amr_genes or get_amr_gene -> fetch_sequence with the RefSeq accession
25
+ -> get_pubmed_references with the pubmed_reference IDs. Drug classes are upper-case
26
+ (e.g. BETA-LACTAM, AMINOGLYCOSIDE); call list_drug_classes to see them all.
27
+ """
28
+
29
+ READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
30
+
31
+ mcp = MCPServer("amr-mcp", instructions=INSTRUCTIONS)
32
+
33
+
34
+ class _State:
35
+ """Lazily created shared clients; the catalog is only downloaded on first use."""
36
+
37
+ def __init__(self) -> None:
38
+ self.catalog: Catalog | None = None
39
+ self._catalog_lock = asyncio.Lock()
40
+ self.ncbi = NCBIClient()
41
+ self.uniprot = UniProtClient()
42
+
43
+ async def get_catalog(self) -> Catalog:
44
+ async with self._catalog_lock:
45
+ if self.catalog is None:
46
+ self.catalog = await load_catalog()
47
+ return self.catalog
48
+
49
+
50
+ state = _State()
51
+
52
+
53
+ def _user_errors(fn):
54
+ """Surface expected failures to the model; MCPServer hides plain exceptions' text."""
55
+
56
+ @functools.wraps(fn)
57
+ async def wrapper(*args, **kwargs):
58
+ try:
59
+ return await fn(*args, **kwargs)
60
+ except (ValueError, RuntimeError) as exc:
61
+ raise ToolError(str(exc)) from exc
62
+ except httpx.HTTPStatusError as exc:
63
+ raise ToolError(f"{exc.request.url.host} returned HTTP {exc.response.status_code}") from exc
64
+ except httpx.HTTPError as exc:
65
+ raise ToolError(f"Network error talking to {exc.request.url.host}: {exc}") from exc
66
+
67
+ return wrapper
68
+
69
+
70
+ @mcp.tool(annotations=READ_ONLY)
71
+ @_user_errors
72
+ async def catalog_info() -> dict[str, Any]:
73
+ """Version and size of the NCBI Reference Gene Catalog in use."""
74
+ return (await state.get_catalog()).info()
75
+
76
+
77
+ @mcp.tool(annotations=READ_ONLY)
78
+ @_user_errors
79
+ async def list_drug_classes() -> dict[str, Any]:
80
+ """All drug classes in the catalog with the number of entries for each."""
81
+ return {"drug_classes": (await state.get_catalog()).drug_classes()}
82
+
83
+
84
+ @mcp.tool(annotations=READ_ONLY)
85
+ @_user_errors
86
+ async def search_amr_genes(
87
+ query: Annotated[
88
+ str | None, Field(description="Text in allele, gene family, product name or synonyms, e.g. 'KPC', 'mcr', 'carbapenemase'")
89
+ ] = None,
90
+ drug_class: Annotated[str | None, Field(description="e.g. BETA-LACTAM, COLISTIN, AMINOGLYCOSIDE")] = None,
91
+ subclass: Annotated[str | None, Field(description="e.g. CARBAPENEM, CEPHALOSPORIN")] = None,
92
+ organism: Annotated[
93
+ str | None, Field(description="Restrict to entries valid for this organism, e.g. 'Klebsiella pneumoniae'")
94
+ ] = None,
95
+ element_type: Annotated[Literal["AMR", "STRESS", "VIRULENCE"] | None, Field(description="Element type")] = None,
96
+ limit: Annotated[int, Field(ge=1, le=200)] = 25,
97
+ ) -> dict[str, Any]:
98
+ """Search the NCBI Reference Gene Catalog of AMR, stress and virulence genes and point mutations."""
99
+ catalog = await state.get_catalog()
100
+ return catalog.search(query, drug_class, subclass, organism, element_type, limit)
101
+
102
+
103
+ @mcp.tool(annotations=READ_ONLY)
104
+ @_user_errors
105
+ async def get_amr_gene(
106
+ name: Annotated[str, Field(description="Exact allele (e.g. 'blaKPC-2') or gene family (e.g. 'blaKPC')")],
107
+ ) -> dict[str, Any]:
108
+ """Full catalog record for an allele, or a summary of all alleles in a gene family."""
109
+ result = (await state.get_catalog()).get(name)
110
+ if result["match"] is None:
111
+ raise ValueError(f"'{name}' is not in the catalog; try search_amr_genes for a fuzzy match")
112
+ return result
113
+
114
+
115
+ @mcp.tool(annotations=READ_ONLY)
116
+ @_user_errors
117
+ async def fetch_sequence(
118
+ accession: Annotated[str, Field(description="NCBI accession, e.g. WP_004199234.1 or NG_049253.1")],
119
+ kind: Literal["protein", "nucleotide"] = "protein",
120
+ ) -> dict[str, Any]:
121
+ """Fetch a protein or nucleotide sequence from NCBI in FASTA form."""
122
+ return await state.ncbi.fetch_fasta(accession, kind)
123
+
124
+
125
+ @mcp.tool(annotations=READ_ONLY)
126
+ @_user_errors
127
+ async def get_pubmed_references(
128
+ pmids: Annotated[list[str], Field(description="PubMed IDs, e.g. from a catalog entry's pubmed_reference", max_length=50)],
129
+ ) -> dict[str, Any]:
130
+ """Title, journal, date, authors and DOI for PubMed articles."""
131
+ return {"articles": await state.ncbi.pubmed_summaries(pmids)}
132
+
133
+
134
+ @mcp.tool(annotations=READ_ONLY)
135
+ @_user_errors
136
+ async def search_uniprot(
137
+ query: Annotated[str, Field(description="UniProt query syntax, e.g. 'gene:KPC-2' or 'carbapenemase AND organism_id:573'")],
138
+ reviewed_only: Annotated[bool, Field(description="Only Swiss-Prot (manually reviewed) entries")] = False,
139
+ limit: Annotated[int, Field(ge=1, le=100)] = 10,
140
+ ) -> dict[str, Any]:
141
+ """Search UniProtKB proteins."""
142
+ return await state.uniprot.search(query, reviewed_only, limit)
143
+
144
+
145
+ @mcp.tool(annotations=READ_ONLY)
146
+ @_user_errors
147
+ async def get_uniprot_entry(
148
+ accession: Annotated[str, Field(description="UniProt accession, e.g. Q9F663")],
149
+ ) -> dict[str, Any]:
150
+ """Function, keywords, cross-references and sequence of a UniProtKB entry."""
151
+ return await state.uniprot.entry(accession)
152
+
153
+
154
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
155
+ @_user_errors
156
+ async def run_amrfinder(
157
+ fasta_path: Annotated[str, Field(description="Absolute path to a local FASTA file")],
158
+ sequence_type: Literal["nucleotide", "protein"] = "nucleotide",
159
+ organism: Annotated[
160
+ str | None, Field(description="Enables point-mutation screening, e.g. 'Escherichia', 'Klebsiella_pneumoniae'")
161
+ ] = None,
162
+ ) -> dict[str, Any]:
163
+ """Screen a local genome or proteome for AMR genes with AMRFinderPlus (must be installed locally)."""
164
+ return await amrfinder.run(fasta_path, sequence_type, organism)
165
+
166
+
167
+ def main() -> None:
168
+ mcp.run()
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()
@@ -0,0 +1,76 @@
1
+ """Minimal UniProtKB REST client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ UNIPROT = "https://rest.uniprot.org/uniprotkb"
8
+ SEARCH_FIELDS = "accession,id,protein_name,gene_names,organism_name,length,reviewed"
9
+
10
+
11
+ class UniProtClient:
12
+ def __init__(self, client: httpx.AsyncClient | None = None) -> None:
13
+ self._client = client or httpx.AsyncClient(timeout=30, follow_redirects=True)
14
+
15
+ async def aclose(self) -> None:
16
+ await self._client.aclose()
17
+
18
+ async def search(self, query: str, reviewed_only: bool = False, limit: int = 10) -> dict:
19
+ q = f"({query}) AND (reviewed:true)" if reviewed_only else query
20
+ resp = await self._client.get(
21
+ f"{UNIPROT}/search",
22
+ params={"query": q, "fields": SEARCH_FIELDS, "format": "json", "size": str(limit)},
23
+ )
24
+ resp.raise_for_status()
25
+ results = [_summarize(e) for e in resp.json().get("results", [])]
26
+ return {
27
+ "query": q,
28
+ "total_matches": int(resp.headers.get("x-total-results", len(results))),
29
+ "results": results,
30
+ }
31
+
32
+ async def entry(self, accession: str) -> dict:
33
+ resp = await self._client.get(f"{UNIPROT}/{accession.strip()}", params={"format": "json"})
34
+ if resp.status_code in (400, 404):
35
+ raise ValueError(f"UniProt entry '{accession}' not found")
36
+ resp.raise_for_status()
37
+ e = resp.json()
38
+ comments = {
39
+ c["commentType"]: " ".join(t["value"] for t in c.get("texts", []))
40
+ for c in e.get("comments", [])
41
+ if c.get("texts")
42
+ }
43
+ return {
44
+ **_summarize(e),
45
+ "function": comments.get("FUNCTION"),
46
+ "catalytic_activity": comments.get("CATALYTIC ACTIVITY"),
47
+ "keywords": [k["name"] for k in e.get("keywords", [])],
48
+ "cross_references": _xrefs(e, {"RefSeq", "EMBL", "PDB", "Pfam", "InterPro", "CARD"}),
49
+ "sequence": e.get("sequence", {}).get("value"),
50
+ "url": f"https://www.uniprot.org/uniprotkb/{e['primaryAccession']}",
51
+ }
52
+
53
+
54
+ def _summarize(e: dict) -> dict:
55
+ desc = e.get("proteinDescription", {})
56
+ name = (
57
+ desc.get("recommendedName", {}).get("fullName", {}).get("value")
58
+ or next(iter(desc.get("submissionNames", [])), {}).get("fullName", {}).get("value")
59
+ )
60
+ return {
61
+ "accession": e.get("primaryAccession"),
62
+ "entry_name": e.get("uniProtkbId"),
63
+ "protein_name": name,
64
+ "genes": [g["geneName"]["value"] for g in e.get("genes", []) if "geneName" in g],
65
+ "organism": e.get("organism", {}).get("scientificName"),
66
+ "length": e.get("sequence", {}).get("length"),
67
+ "reviewed": e.get("entryType", "").startswith("UniProtKB reviewed"),
68
+ }
69
+
70
+
71
+ def _xrefs(e: dict, dbs: set[str]) -> dict[str, list[str]]:
72
+ out: dict[str, list[str]] = {}
73
+ for x in e.get("uniProtKBCrossReferences", []):
74
+ if x["database"] in dbs:
75
+ out.setdefault(x["database"], []).append(x["id"])
76
+ return {k: v[:20] for k, v in out.items()}
@@ -0,0 +1,10 @@
1
+ allele gene_family whitelisted_taxa product_name scope type subtype class subclass refseq_protein_accession refseq_nucleotide_accession curated_refseq_start genbank_protein_accession genbank_nucleotide_accession genbank_strand genbank_start genbank_stop refseq_strand refseq_start refseq_stop pubmed_reference blacklisted_taxa synonyms hierarchy_node db_version
2
+ blaKPC-2 blaKPC carbapenem-hydrolyzing class A beta-lactamase KPC-2 core AMR AMR BETA-LACTAM CARBAPENEM WP_004199234.1 NG_049253.1 No AAK70220.1 AY034847.1 + 6 887 + 6 887 12615876,37255469 blaKPC-1 blaKPC-2 2026-10-29.1
3
+ blaKPC-20 blaKPC carbapenem-hydrolyzing class A beta-lactamase KPC-20 core AMR AMR BETA-LACTAM CARBAPENEM WP_436452774.1 NG_245797.1 No MGK4321900.1 JBPQFE010000002.1 + 33852 34733 + 101 982 blaKPC-20 2026-10-29.1
4
+ blaKPC-3 blaKPC carbapenem-hydrolyzing class A beta-lactamase KPC-3 core AMR AMR BETA-LACTAM CARBAPENEM WP_004152396.1 NG_049257.1 No AAL05630.1 AF395881.1 + 1 882 + 1 882 15561858,19015357,37255469 blaKPC-3 2026-10-29.1
5
+ mcr-1.1 mcr-1 phosphoethanolamine--lipid A transferase MCR-1.1 core AMR AMR COLISTIN COLISTIN WP_049589868.1 NG_050417.1 No AKF16168.1 KP347127.1 + 22413 24038 + 101 1726 26603172 mcr-1.1 2026-10-29.1
6
+ adeL_G27R adeL Acinetobacter_baumannii multidrug efflux transcriptional repressor AdeL core AMR POINT TETRACYCLINE TIGECYCLINE WP_002000718.1 NZ_CP091335.1 No UMO35973.1 CP091335.1 + 73589 74620 + 73589 74620 35704614 2026-10-29.1
7
+ arsB arsenite efflux transporter membrane subunit ArsB plus STRESS METAL ARSENIC ARSENITE WP_004152098.1 No BAA24823.1 AB004659.1 + 3095 4384 Escherichia arsB_pKW301 2026-10-29.1
8
+ kdeA multidrug efflux MFS transporter KdeA plus AMR AMR EFFLUX EFFLUX WP_004141972.1 No ABR76310.1 CP000647.1 + 980459 981691 17917272 Klebsiella_pneumoniae kdeA 2026-10-29.1
9
+ aafA aggregative adherence fimbria II major subunit AafA plus VIRULENCE VIRULENCE WP_077767014.1 No BAJ79293.1 AB571091.1 + 34 516 aafA 2026-10-29.1
10
+ 16S_A523C 16S Escherichia 16S ribosomal RNA core AMR POINT AMINOGLYCOSIDE STREPTOMYCIN NC_000913.3 U00096.3 + 4166659 4168201 + 4166659 4168201 3054810 2026-10-29.1
@@ -0,0 +1,79 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from amr_mcp.catalog import Catalog
6
+
7
+ FIXTURE = Path(__file__).parent / "fixtures" / "catalog_sample.tsv"
8
+
9
+
10
+ @pytest.fixture
11
+ def catalog() -> Catalog:
12
+ return Catalog.from_file(FIXTURE)
13
+
14
+
15
+ def test_info(catalog):
16
+ info = catalog.info()
17
+ assert info["total_entries"] == 9
18
+ assert info["by_type"]["AMR"] == 7
19
+ assert info["db_version"] != "unknown"
20
+
21
+
22
+ def test_drug_classes_sorted_by_count(catalog):
23
+ classes = catalog.drug_classes()
24
+ assert classes[0] == {"class": "BETA-LACTAM", "entries": 3}
25
+
26
+
27
+ def test_search_by_text_is_case_insensitive(catalog):
28
+ result = catalog.search(query="kpc")
29
+ assert result["total_matches"] == 3
30
+ assert {r["allele"] for r in result["results"]} == {"blaKPC-2", "blaKPC-3", "blaKPC-20"}
31
+
32
+
33
+ def test_search_limit(catalog):
34
+ result = catalog.search(query="kpc", limit=1)
35
+ assert result["total_matches"] == 3
36
+ assert result["returned"] == 1
37
+
38
+
39
+ def test_search_by_class_and_type(catalog):
40
+ assert catalog.search(drug_class="colistin")["results"][0]["allele"] == "mcr-1.1"
41
+ assert catalog.search(element_type="VIRULENCE")["total_matches"] == 1
42
+
43
+
44
+ def test_organism_whitelist(catalog):
45
+ # Acinetobacter-only point mutation is excluded for E. coli but included for A. baumannii.
46
+ assert "adeL_G27R" not in _alleles(catalog.search(organism="Escherichia coli", limit=100))
47
+ assert "adeL_G27R" in _alleles(catalog.search(organism="Acinetobacter baumannii", limit=100))
48
+ # Genus-level whitelist matches a species of that genus.
49
+ assert "16S_A523C" in _alleles(catalog.search(organism="Escherichia coli", limit=100))
50
+
51
+
52
+ def test_organism_blacklist(catalog):
53
+ families = {r["gene_family"] for r in catalog.search(organism="Escherichia coli", limit=100)["results"]}
54
+ assert "arsB" not in families
55
+ assert "arsB" in {r["gene_family"] for r in catalog.search(organism="Salmonella enterica", limit=100)["results"]}
56
+
57
+
58
+ def test_get_allele(catalog):
59
+ result = catalog.get("BLAKPC-2")
60
+ assert result["match"] == "allele"
61
+ entry = result["entries"][0]
62
+ assert entry["refseq_protein_accession"].startswith("WP_")
63
+ # Empty columns are dropped to keep responses small.
64
+ assert "whitelisted_taxa" not in entry
65
+
66
+
67
+ def test_get_gene_family(catalog):
68
+ result = catalog.get("blaKPC")
69
+ assert result["match"] == "gene_family"
70
+ assert result["allele_count"] == 3
71
+ assert result["subclasses"] == ["CARBAPENEM"]
72
+
73
+
74
+ def test_get_unknown(catalog):
75
+ assert catalog.get("notagene")["match"] is None
76
+
77
+
78
+ def _alleles(result: dict) -> set[str]:
79
+ return {r.get("allele") for r in result["results"]}
@@ -0,0 +1,47 @@
1
+ """Smoke tests against the real NCBI and UniProt APIs. Skip with: pytest -m 'not network'."""
2
+
3
+ import pytest
4
+
5
+ from amr_mcp.catalog import load_catalog
6
+ from amr_mcp.ncbi import NCBIClient
7
+ from amr_mcp.uniprot import UniProtClient
8
+
9
+ pytestmark = pytest.mark.network
10
+
11
+
12
+ async def test_download_full_catalog(tmp_path):
13
+ catalog = await load_catalog(cache_dir=tmp_path)
14
+ assert len(catalog.rows) > 5000
15
+ assert catalog.get("blaKPC-2")["match"] == "allele"
16
+
17
+
18
+ async def test_fetch_kpc2_protein():
19
+ ncbi = NCBIClient()
20
+ try:
21
+ seq = await ncbi.fetch_fasta("WP_004199234.1", "protein")
22
+ finally:
23
+ await ncbi.aclose()
24
+ assert "KPC" in seq["header"]
25
+ assert 280 <= seq["length"] <= 300
26
+
27
+
28
+ async def test_pubmed_summary():
29
+ ncbi = NCBIClient()
30
+ try:
31
+ refs = await ncbi.pubmed_summaries(["12615876"])
32
+ finally:
33
+ await ncbi.aclose()
34
+ assert refs[0]["pmid"] == "12615876"
35
+ assert refs[0]["title"]
36
+
37
+
38
+ async def test_uniprot_search_and_entry():
39
+ uniprot = UniProtClient()
40
+ try:
41
+ found = await uniprot.search("gene:KPC-2", reviewed_only=True, limit=3)
42
+ assert found["results"]
43
+ entry = await uniprot.entry(found["results"][0]["accession"])
44
+ finally:
45
+ await uniprot.aclose()
46
+ assert entry["sequence"]
47
+ assert entry["reviewed"] is True
@@ -0,0 +1,55 @@
1
+ """End-to-end tests through an in-process MCP client."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from mcp import Client
7
+
8
+ from amr_mcp import server
9
+ from amr_mcp.catalog import Catalog
10
+
11
+ FIXTURE = Path(__file__).parent / "fixtures" / "catalog_sample.tsv"
12
+
13
+ EXPECTED_TOOLS = {
14
+ "catalog_info",
15
+ "list_drug_classes",
16
+ "search_amr_genes",
17
+ "get_amr_gene",
18
+ "fetch_sequence",
19
+ "get_pubmed_references",
20
+ "search_uniprot",
21
+ "get_uniprot_entry",
22
+ "run_amrfinder",
23
+ }
24
+
25
+
26
+ @pytest.fixture(autouse=True)
27
+ def offline_catalog(monkeypatch):
28
+ monkeypatch.setattr(server.state, "catalog", Catalog.from_file(FIXTURE))
29
+
30
+
31
+ async def test_lists_all_tools():
32
+ async with Client(server.mcp) as client:
33
+ tools = {t.name for t in (await client.list_tools()).tools}
34
+ assert tools == EXPECTED_TOOLS
35
+
36
+
37
+ async def test_search_tool_returns_structured_content():
38
+ async with Client(server.mcp) as client:
39
+ result = await client.call_tool("search_amr_genes", {"query": "KPC", "limit": 2})
40
+ assert not result.is_error
41
+ assert result.structured_content["total_matches"] == 3
42
+ assert len(result.structured_content["results"]) == 2
43
+
44
+
45
+ async def test_unknown_gene_is_a_tool_error():
46
+ async with Client(server.mcp) as client:
47
+ result = await client.call_tool("get_amr_gene", {"name": "notagene"})
48
+ assert result.is_error
49
+ assert "search_amr_genes" in result.content[0].text
50
+
51
+
52
+ async def test_bad_accession_is_rejected_before_any_request():
53
+ async with Client(server.mcp) as client:
54
+ result = await client.call_tool("fetch_sequence", {"accession": "rm -rf /"})
55
+ assert result.is_error