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.
- amr_mcp-0.1.0/.github/workflows/ci.yml +15 -0
- amr_mcp-0.1.0/.github/workflows/release.yml +80 -0
- amr_mcp-0.1.0/.gitignore +5 -0
- amr_mcp-0.1.0/LICENSE +21 -0
- amr_mcp-0.1.0/PKG-INFO +115 -0
- amr_mcp-0.1.0/README.md +95 -0
- amr_mcp-0.1.0/pyproject.toml +36 -0
- amr_mcp-0.1.0/server.json +34 -0
- amr_mcp-0.1.0/src/amr_mcp/__init__.py +3 -0
- amr_mcp-0.1.0/src/amr_mcp/amrfinder.py +47 -0
- amr_mcp-0.1.0/src/amr_mcp/catalog.py +176 -0
- amr_mcp-0.1.0/src/amr_mcp/ncbi.py +99 -0
- amr_mcp-0.1.0/src/amr_mcp/server.py +172 -0
- amr_mcp-0.1.0/src/amr_mcp/uniprot.py +76 -0
- amr_mcp-0.1.0/tests/fixtures/catalog_sample.tsv +10 -0
- amr_mcp-0.1.0/tests/test_catalog.py +79 -0
- amr_mcp-0.1.0/tests/test_live.py +47 -0
- amr_mcp-0.1.0/tests/test_server.py +55 -0
|
@@ -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
|
amr_mcp-0.1.0/.gitignore
ADDED
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
|
+
[](https://github.com/pabloestepaa/amr-mcp/actions/workflows/ci.yml)
|
|
26
|
+
[](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.
|
amr_mcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# amr-mcp
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.pabloestepaa/amr-mcp -->
|
|
4
|
+
|
|
5
|
+
[](https://github.com/pabloestepaa/amr-mcp/actions/workflows/ci.yml)
|
|
6
|
+
[](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,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
|