pubmed-access-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,201 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. Please also get an approval
186
+ for your license configuration from the ASF (Apache Software
187
+ Foundation).
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: pubmed-access-mcp
3
+ Version: 0.1.0
4
+ Summary: A minimal, auditable Model Context Protocol (MCP) server for PubMed via NCBI E-utilities with open-access detection.
5
+ Author: ahsanmandhar-ui
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/ahsanmandhar-ui/pubmed-mcp
8
+ Project-URL: Repository, https://github.com/ahsanmandhar-ui/pubmed-mcp
9
+ Project-URL: Issues, https://github.com/ahsanmandhar-ui/pubmed-mcp/issues
10
+ Keywords: mcp,pubmed,ncbi,open-access,model-context-protocol,ai,research
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: mcp<3,>=1.2.0
22
+ Requires-Dist: requests>=2.31.0
23
+ Dynamic: license-file
24
+
25
+ # pubmed-mcp
26
+
27
+ A minimal, auditable [Model Context Protocol](https://modelcontextprotocol.io) server for PubMed via NCBI E-utilities.
28
+ Built for evidence synthesis: the server returns only what NCBI returns, and flags everything else.
29
+
30
+ ## Quick install
31
+
32
+ pip install pubmed-access-mcp
33
+
34
+ Or run without installing:
35
+
36
+ uvx pubmed-access-mcp
37
+
38
+ ## Tools
39
+ | Tool | Purpose |
40
+ |---|---|
41
+ | `search_pubmed(query, max_results=20, sort="relevance", date_from=None, date_to=None)` | Returns PMIDs, `total_matches`, and `query_translation` (how PubMed parsed your query; log it in your methods). |
42
+ | `fetch_abstracts(pmids)` | Returns title, authors, journal, date, DOI, publication types, retraction-notice flag, abstract (section labels kept), URL, up to 200 PMIDs per call. |
43
+ | `search_with_access(query, max_results<=100, ...)` | Search, then list each hit as open-access PDF / landing page only / no open access found / unchecked. |
44
+ | `check_access(pmids)` | Same classification for given PMIDs. |
45
+ | `download_pdfs(pmids, folder=None)` | Saves open-access PDFs as PMID<id>.pdf; paywalled papers are skipped, never bypassed. |
46
+
47
+ ## Guarantees
48
+ - Missing field => literal `Data not provided in PubMed abstract`. Nothing is inferred or paraphrased.
49
+ - Requested PMIDs that NCBI did not return are listed in `not_found`.
50
+ - `publication_types` is NCBI's own tag. **No evidence level (CEBM/GRADE) is assigned by the server**: abstracts alone are not enough to grade evidence.
51
+ - `has_retraction_notice` is true only if the record carries an NCBI "RetractionIn" link.
52
+ - Respects NCBI rate limits (3 req/s, 10 req/s with an API key) with retry/backoff.
53
+
54
+ ## Open access and PDFs
55
+ Sources are Unpaywall (via DOI) and PubMed Central (via PMCID). Set `UNPAYWALL_EMAIL` or `NCBI_EMAIL`. `NO_OPEN_ACCESS_FOUND` means no legal free copy is indexed, not that an institution cannot reach it. Every file is checked to start with `%PDF` and bot-check pages are rejected. Files go to `PUBMED_PDF_DIR` (default `~/pubmed_pdfs`). No paywall bypass.
56
+
57
+ ## Register in your MCP client
58
+
59
+ ### After `pip install pubmed-access-mcp`
60
+ Add to your MCP client config (Claude Desktop, Antigravity, Cursor, etc.):
61
+
62
+ {"mcpServers": {"pubmed-scraper": {
63
+ "command": "pubmed-access-mcp",
64
+ "args": [],
65
+ "env": {"NCBI_EMAIL": "you@example.com", "UNPAYWALL_EMAIL": "you@example.com"}}}}
66
+
67
+ ### With `uvx` (no install needed)
68
+
69
+ {"mcpServers": {"pubmed-scraper": {
70
+ "command": "uvx",
71
+ "args": ["pubmed-access-mcp"],
72
+ "env": {"NCBI_EMAIL": "you@example.com", "UNPAYWALL_EMAIL": "you@example.com"}}}}
73
+
74
+ ### From source (development)
75
+
76
+ git clone https://github.com/ahsanmandhar-ui/pubmed-mcp.git
77
+ cd pubmed-mcp
78
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
79
+ pip install -r requirements.txt
80
+
81
+ Then point your MCP config to the absolute path of `.venv/bin/python` (or `.venv\Scripts\python.exe`) and `server.py`.
82
+
83
+ Optional env: `NCBI_API_KEY` (free, raises rate limit), `NCBI_EMAIL` (NCBI usage policy asks for one), `NCBI_TOOL`.
84
+
85
+ Put your real `NCBI_API_KEY` / email only in your GLOBAL config, never in a file you commit.
86
+
87
+ ## Install (for development / running tests)
88
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
89
+ pip install -r requirements.txt
90
+ python tests/test_server.py # offline tests (synthetic fixture, no network)
91
+ python tests/test_access.py # offline open-access tests
92
+
93
+ ## Limitations
94
+ - Abstract-level data only; no full text. Not a substitute for a full systematic-review search strategy across Embase, Cochrane, etc.
95
+ - Search quality depends on your query syntax (MeSH, field tags); PubMed may translate it unexpectedly, so check `query_translation`.
96
+ - Open-access coverage varies, and some publishers block automated downloads.
97
+ - Live NCBI, Unpaywall, and PDF download behaviour was not covered by automated tests (they use synthetic fixtures).
98
+
99
+ ## License
100
+ Apache License 2.0, see `LICENSE`. Copyright 2024 ahsanmandhar-ui.
@@ -0,0 +1,76 @@
1
+ # pubmed-mcp
2
+
3
+ A minimal, auditable [Model Context Protocol](https://modelcontextprotocol.io) server for PubMed via NCBI E-utilities.
4
+ Built for evidence synthesis: the server returns only what NCBI returns, and flags everything else.
5
+
6
+ ## Quick install
7
+
8
+ pip install pubmed-access-mcp
9
+
10
+ Or run without installing:
11
+
12
+ uvx pubmed-access-mcp
13
+
14
+ ## Tools
15
+ | Tool | Purpose |
16
+ |---|---|
17
+ | `search_pubmed(query, max_results=20, sort="relevance", date_from=None, date_to=None)` | Returns PMIDs, `total_matches`, and `query_translation` (how PubMed parsed your query; log it in your methods). |
18
+ | `fetch_abstracts(pmids)` | Returns title, authors, journal, date, DOI, publication types, retraction-notice flag, abstract (section labels kept), URL, up to 200 PMIDs per call. |
19
+ | `search_with_access(query, max_results<=100, ...)` | Search, then list each hit as open-access PDF / landing page only / no open access found / unchecked. |
20
+ | `check_access(pmids)` | Same classification for given PMIDs. |
21
+ | `download_pdfs(pmids, folder=None)` | Saves open-access PDFs as PMID<id>.pdf; paywalled papers are skipped, never bypassed. |
22
+
23
+ ## Guarantees
24
+ - Missing field => literal `Data not provided in PubMed abstract`. Nothing is inferred or paraphrased.
25
+ - Requested PMIDs that NCBI did not return are listed in `not_found`.
26
+ - `publication_types` is NCBI's own tag. **No evidence level (CEBM/GRADE) is assigned by the server**: abstracts alone are not enough to grade evidence.
27
+ - `has_retraction_notice` is true only if the record carries an NCBI "RetractionIn" link.
28
+ - Respects NCBI rate limits (3 req/s, 10 req/s with an API key) with retry/backoff.
29
+
30
+ ## Open access and PDFs
31
+ Sources are Unpaywall (via DOI) and PubMed Central (via PMCID). Set `UNPAYWALL_EMAIL` or `NCBI_EMAIL`. `NO_OPEN_ACCESS_FOUND` means no legal free copy is indexed, not that an institution cannot reach it. Every file is checked to start with `%PDF` and bot-check pages are rejected. Files go to `PUBMED_PDF_DIR` (default `~/pubmed_pdfs`). No paywall bypass.
32
+
33
+ ## Register in your MCP client
34
+
35
+ ### After `pip install pubmed-access-mcp`
36
+ Add to your MCP client config (Claude Desktop, Antigravity, Cursor, etc.):
37
+
38
+ {"mcpServers": {"pubmed-scraper": {
39
+ "command": "pubmed-access-mcp",
40
+ "args": [],
41
+ "env": {"NCBI_EMAIL": "you@example.com", "UNPAYWALL_EMAIL": "you@example.com"}}}}
42
+
43
+ ### With `uvx` (no install needed)
44
+
45
+ {"mcpServers": {"pubmed-scraper": {
46
+ "command": "uvx",
47
+ "args": ["pubmed-access-mcp"],
48
+ "env": {"NCBI_EMAIL": "you@example.com", "UNPAYWALL_EMAIL": "you@example.com"}}}}
49
+
50
+ ### From source (development)
51
+
52
+ git clone https://github.com/ahsanmandhar-ui/pubmed-mcp.git
53
+ cd pubmed-mcp
54
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
55
+ pip install -r requirements.txt
56
+
57
+ Then point your MCP config to the absolute path of `.venv/bin/python` (or `.venv\Scripts\python.exe`) and `server.py`.
58
+
59
+ Optional env: `NCBI_API_KEY` (free, raises rate limit), `NCBI_EMAIL` (NCBI usage policy asks for one), `NCBI_TOOL`.
60
+
61
+ Put your real `NCBI_API_KEY` / email only in your GLOBAL config, never in a file you commit.
62
+
63
+ ## Install (for development / running tests)
64
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
65
+ pip install -r requirements.txt
66
+ python tests/test_server.py # offline tests (synthetic fixture, no network)
67
+ python tests/test_access.py # offline open-access tests
68
+
69
+ ## Limitations
70
+ - Abstract-level data only; no full text. Not a substitute for a full systematic-review search strategy across Embase, Cochrane, etc.
71
+ - Search quality depends on your query syntax (MeSH, field tags); PubMed may translate it unexpectedly, so check `query_translation`.
72
+ - Open-access coverage varies, and some publishers block automated downloads.
73
+ - Live NCBI, Unpaywall, and PDF download behaviour was not covered by automated tests (they use synthetic fixtures).
74
+
75
+ ## License
76
+ Apache License 2.0, see `LICENSE`. Copyright 2024 ahsanmandhar-ui.
@@ -0,0 +1,203 @@
1
+ """Open-access discovery and PDF download for PubMed records.
2
+
3
+ Rules (same philosophy as server.py): report only what the sources return; never guess.
4
+ Sources: DOI -> Unpaywall (legal open-access locations); PMCID (from PubMed) -> PubMed Central.
5
+ No paywall bypass, no shadow libraries. A paper with no open-access copy is reported as such.
6
+ """
7
+ import ipaddress
8
+ import os
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Callable, List, Optional
12
+ from urllib.parse import quote, urlparse
13
+
14
+ import requests
15
+
16
+ MISSING = "Data not provided in PubMed abstract"
17
+ NO_PDF = "No open-access PDF found"
18
+ UNPAYWALL = "https://api.unpaywall.org/v2"
19
+ PMC_PDF = "https://pmc.ncbi.nlm.nih.gov/articles/{pmcid}/pdf/"
20
+ PMC_PAGE = "https://pmc.ncbi.nlm.nih.gov/articles/{pmcid}/"
21
+ MAX_BYTES = 60 * 1024 * 1024
22
+ UA = {"User-Agent": "pubmed-mcp/1.0 (open-source research tool)"}
23
+
24
+ OPEN_PDF = "OPEN_ACCESS_PDF"
25
+ OPEN_LANDING = "OPEN_ACCESS_LANDING_PAGE_ONLY"
26
+ NO_OA = "NO_OPEN_ACCESS_FOUND"
27
+ UNCHECKED = "UNCHECKED"
28
+ NO_OA_NOTE = ("No legal open-access copy was indexed by Unpaywall/PubMed Central at check time. Likely paywalled; "
29
+ "it may still be reachable through your institution, or as an author copy/preprint that is not indexed.")
30
+
31
+
32
+ def _clean(v: Optional[str]) -> Optional[str]:
33
+ v = (v or "").strip()
34
+ return None if (not v or v.upper().startswith("YOUR_") or v.startswith("${")) else v
35
+
36
+
37
+ def unpaywall_email() -> Optional[str]:
38
+ return _clean(os.environ.get("UNPAYWALL_EMAIL")) or _clean(os.environ.get("NCBI_EMAIL"))
39
+
40
+
41
+ def _safe_url(u: str) -> bool:
42
+ p = urlparse(u or "")
43
+ if p.scheme not in ("http", "https") or not p.hostname:
44
+ return False
45
+ if p.hostname.lower() == "localhost":
46
+ return False
47
+ try:
48
+ ip = ipaddress.ip_address(p.hostname)
49
+ return not (ip.is_private or ip.is_loopback or ip.is_link_local)
50
+ except ValueError:
51
+ return True # a normal hostname
52
+
53
+
54
+ def _unpaywall(doi: str, email: str, get: Callable):
55
+ try:
56
+ r = get(f"{UNPAYWALL}/{quote(doi, safe='/:()')}", params={"email": email}, headers=UA, timeout=30)
57
+ except requests.RequestException as e:
58
+ return None, f"Unpaywall request failed: {type(e).__name__}"
59
+ if r.status_code == 404:
60
+ return None, "DOI not found in Unpaywall"
61
+ if r.status_code != 200:
62
+ return None, f"Unpaywall HTTP {r.status_code}"
63
+ try:
64
+ return r.json(), None
65
+ except ValueError:
66
+ return None, "Unpaywall returned invalid JSON"
67
+
68
+
69
+ def classify(rec: dict, email: Optional[str], get: Callable = requests.get) -> dict:
70
+ doi, pmcid = rec.get("doi", MISSING), rec.get("pmcid", MISSING)
71
+ has_doi, has_pmc = doi != MISSING, pmcid != MISSING
72
+ cands, landing, oa_status, note, lookup_ok, is_oa = [], None, "not_checked", "", False, None
73
+ if has_doi and email:
74
+ data, err = _unpaywall(doi, email, get)
75
+ if data:
76
+ lookup_ok, is_oa = True, data.get("is_oa")
77
+ oa_status = data.get("oa_status") or MISSING
78
+ locs = ([data["best_oa_location"]] if data.get("best_oa_location") else []) + (data.get("oa_locations") or [])
79
+ seen = set()
80
+ for loc in locs:
81
+ pdf, page = loc.get("url_for_pdf"), loc.get("url")
82
+ if pdf and pdf not in seen and _safe_url(pdf):
83
+ seen.add(pdf)
84
+ cands.append({"url": pdf, "source": f"Unpaywall ({loc.get('host_type') or 'unknown host'})",
85
+ "license": loc.get("license") or MISSING, "version": loc.get("version") or MISSING})
86
+ elif page and landing is None and _safe_url(page):
87
+ landing = page
88
+ else:
89
+ note = err
90
+ elif has_doi:
91
+ note = "Set UNPAYWALL_EMAIL (or NCBI_EMAIL) to enable the Unpaywall lookup."
92
+ if has_pmc:
93
+ cands.append({"url": PMC_PDF.format(pmcid=pmcid), "source": "PubMed Central", "license": MISSING, "version": MISSING})
94
+ landing = landing or PMC_PAGE.format(pmcid=pmcid)
95
+ if cands:
96
+ access = OPEN_PDF
97
+ elif landing:
98
+ access = OPEN_LANDING
99
+ elif lookup_ok and not is_oa:
100
+ access, note = NO_OA, NO_OA_NOTE
101
+ else:
102
+ access = UNCHECKED
103
+ note = note or ("No DOI or PMCID in the PubMed record." if not (has_doi or has_pmc) else "Open-access status could not be determined.")
104
+ return {"pmid": rec.get("pmid"), "title": rec.get("title"), "pub_date": rec.get("pub_date"), "doi": doi, "pmcid": pmcid,
105
+ "has_retraction_notice": rec.get("has_retraction_notice", False), "access": access, "oa_status": oa_status,
106
+ "pdf_candidates": cands, "landing_url": landing or NO_PDF, "note": note}
107
+
108
+
109
+ def check_records(records: List[dict], email: Optional[str], get: Callable = requests.get, delay: float = 0.15) -> List[dict]:
110
+ out = []
111
+ for i, rec in enumerate(records):
112
+ if i and email:
113
+ time.sleep(delay)
114
+ out.append(classify(rec, email, get))
115
+ return out
116
+
117
+
118
+ def summarize(results: List[dict]) -> dict:
119
+ groups = {OPEN_PDF: [], OPEN_LANDING: [], NO_OA: [], UNCHECKED: []}
120
+ for r in results:
121
+ c = r["pdf_candidates"]
122
+ item = {"pmid": r["pmid"], "title": r["title"], "pub_date": r["pub_date"], "doi": r["doi"],
123
+ "oa_status": r["oa_status"], "has_retraction_notice": r["has_retraction_notice"]}
124
+ if r["access"] == OPEN_PDF:
125
+ item.update(pdf_url=c[0]["url"], source=c[0]["source"], other_pdf_sources=len(c) - 1)
126
+ elif r["access"] == OPEN_LANDING:
127
+ item["landing_url"] = r["landing_url"]
128
+ else:
129
+ item["note"] = r["note"]
130
+ groups[r["access"]].append(item)
131
+ return {"counts": {k: len(v) for k, v in groups.items()},
132
+ "open_access_pdf": groups[OPEN_PDF], "open_access_landing_page_only": groups[OPEN_LANDING],
133
+ "no_open_access_found": groups[NO_OA], "unchecked": groups[UNCHECKED]}
134
+
135
+
136
+ def target_dir(folder: Optional[str]) -> Path:
137
+ root = Path(os.environ.get("PUBMED_PDF_DIR") or (Path.home() / "pubmed_pdfs"))
138
+ if folder:
139
+ p = Path(folder)
140
+ if p.is_absolute():
141
+ root = p
142
+ elif ".." in p.parts:
143
+ raise ValueError("folder must not contain '..'")
144
+ else:
145
+ root = root / p
146
+ root.mkdir(parents=True, exist_ok=True)
147
+ return root
148
+
149
+
150
+ def _download(url: str, dest: Path, get: Callable) -> int:
151
+ if not _safe_url(url):
152
+ raise ValueError("unsafe or invalid URL")
153
+ r = get(url, stream=True, timeout=60, headers=UA, allow_redirects=True)
154
+ part = dest.with_suffix(".part")
155
+ try:
156
+ if r.status_code != 200:
157
+ raise ValueError(f"HTTP {r.status_code}")
158
+ if int(r.headers.get("Content-Length") or 0) > MAX_BYTES:
159
+ raise ValueError("file larger than 60 MB limit")
160
+ size, first = 0, True
161
+ with open(part, "wb") as f:
162
+ for chunk in r.iter_content(65536):
163
+ if first:
164
+ if not chunk.startswith(b"%PDF"):
165
+ raise ValueError("response is not a PDF (login, consent or bot-check page)")
166
+ first = False
167
+ size += len(chunk)
168
+ if size > MAX_BYTES:
169
+ raise ValueError("file larger than 60 MB limit")
170
+ f.write(chunk)
171
+ if size == 0:
172
+ raise ValueError("empty response")
173
+ part.replace(dest)
174
+ return size
175
+ finally:
176
+ r.close()
177
+ if part.exists():
178
+ part.unlink()
179
+
180
+
181
+ def download_many(results: List[dict], folder: Path, get: Callable = requests.get, delay: float = 0.5) -> List[dict]:
182
+ out = []
183
+ for r in results:
184
+ pmid = str(r["pmid"])
185
+ dest = folder / f"PMID{pmid}.pdf"
186
+ if not r["pdf_candidates"]:
187
+ out.append({"pmid": pmid, "status": "skipped", "reason": f"no direct open-access PDF link (access={r['access']})"})
188
+ continue
189
+ if dest.exists() and dest.stat().st_size > 0:
190
+ out.append({"pmid": pmid, "status": "already_exists", "file": str(dest)})
191
+ continue
192
+ errors = []
193
+ for c in r["pdf_candidates"]:
194
+ try:
195
+ size = _download(c["url"], dest, get)
196
+ out.append({"pmid": pmid, "status": "downloaded", "file": str(dest), "bytes": size, "source": c["source"], "url": c["url"]})
197
+ break
198
+ except (ValueError, OSError, requests.RequestException) as e:
199
+ errors.append(f"{c['source']}: {e}")
200
+ time.sleep(delay)
201
+ else:
202
+ out.append({"pmid": pmid, "status": "failed", "reason": errors})
203
+ return out
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: pubmed-access-mcp
3
+ Version: 0.1.0
4
+ Summary: A minimal, auditable Model Context Protocol (MCP) server for PubMed via NCBI E-utilities with open-access detection.
5
+ Author: ahsanmandhar-ui
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/ahsanmandhar-ui/pubmed-mcp
8
+ Project-URL: Repository, https://github.com/ahsanmandhar-ui/pubmed-mcp
9
+ Project-URL: Issues, https://github.com/ahsanmandhar-ui/pubmed-mcp/issues
10
+ Keywords: mcp,pubmed,ncbi,open-access,model-context-protocol,ai,research
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: mcp<3,>=1.2.0
22
+ Requires-Dist: requests>=2.31.0
23
+ Dynamic: license-file
24
+
25
+ # pubmed-mcp
26
+
27
+ A minimal, auditable [Model Context Protocol](https://modelcontextprotocol.io) server for PubMed via NCBI E-utilities.
28
+ Built for evidence synthesis: the server returns only what NCBI returns, and flags everything else.
29
+
30
+ ## Quick install
31
+
32
+ pip install pubmed-access-mcp
33
+
34
+ Or run without installing:
35
+
36
+ uvx pubmed-access-mcp
37
+
38
+ ## Tools
39
+ | Tool | Purpose |
40
+ |---|---|
41
+ | `search_pubmed(query, max_results=20, sort="relevance", date_from=None, date_to=None)` | Returns PMIDs, `total_matches`, and `query_translation` (how PubMed parsed your query; log it in your methods). |
42
+ | `fetch_abstracts(pmids)` | Returns title, authors, journal, date, DOI, publication types, retraction-notice flag, abstract (section labels kept), URL, up to 200 PMIDs per call. |
43
+ | `search_with_access(query, max_results<=100, ...)` | Search, then list each hit as open-access PDF / landing page only / no open access found / unchecked. |
44
+ | `check_access(pmids)` | Same classification for given PMIDs. |
45
+ | `download_pdfs(pmids, folder=None)` | Saves open-access PDFs as PMID<id>.pdf; paywalled papers are skipped, never bypassed. |
46
+
47
+ ## Guarantees
48
+ - Missing field => literal `Data not provided in PubMed abstract`. Nothing is inferred or paraphrased.
49
+ - Requested PMIDs that NCBI did not return are listed in `not_found`.
50
+ - `publication_types` is NCBI's own tag. **No evidence level (CEBM/GRADE) is assigned by the server**: abstracts alone are not enough to grade evidence.
51
+ - `has_retraction_notice` is true only if the record carries an NCBI "RetractionIn" link.
52
+ - Respects NCBI rate limits (3 req/s, 10 req/s with an API key) with retry/backoff.
53
+
54
+ ## Open access and PDFs
55
+ Sources are Unpaywall (via DOI) and PubMed Central (via PMCID). Set `UNPAYWALL_EMAIL` or `NCBI_EMAIL`. `NO_OPEN_ACCESS_FOUND` means no legal free copy is indexed, not that an institution cannot reach it. Every file is checked to start with `%PDF` and bot-check pages are rejected. Files go to `PUBMED_PDF_DIR` (default `~/pubmed_pdfs`). No paywall bypass.
56
+
57
+ ## Register in your MCP client
58
+
59
+ ### After `pip install pubmed-access-mcp`
60
+ Add to your MCP client config (Claude Desktop, Antigravity, Cursor, etc.):
61
+
62
+ {"mcpServers": {"pubmed-scraper": {
63
+ "command": "pubmed-access-mcp",
64
+ "args": [],
65
+ "env": {"NCBI_EMAIL": "you@example.com", "UNPAYWALL_EMAIL": "you@example.com"}}}}
66
+
67
+ ### With `uvx` (no install needed)
68
+
69
+ {"mcpServers": {"pubmed-scraper": {
70
+ "command": "uvx",
71
+ "args": ["pubmed-access-mcp"],
72
+ "env": {"NCBI_EMAIL": "you@example.com", "UNPAYWALL_EMAIL": "you@example.com"}}}}
73
+
74
+ ### From source (development)
75
+
76
+ git clone https://github.com/ahsanmandhar-ui/pubmed-mcp.git
77
+ cd pubmed-mcp
78
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
79
+ pip install -r requirements.txt
80
+
81
+ Then point your MCP config to the absolute path of `.venv/bin/python` (or `.venv\Scripts\python.exe`) and `server.py`.
82
+
83
+ Optional env: `NCBI_API_KEY` (free, raises rate limit), `NCBI_EMAIL` (NCBI usage policy asks for one), `NCBI_TOOL`.
84
+
85
+ Put your real `NCBI_API_KEY` / email only in your GLOBAL config, never in a file you commit.
86
+
87
+ ## Install (for development / running tests)
88
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
89
+ pip install -r requirements.txt
90
+ python tests/test_server.py # offline tests (synthetic fixture, no network)
91
+ python tests/test_access.py # offline open-access tests
92
+
93
+ ## Limitations
94
+ - Abstract-level data only; no full text. Not a substitute for a full systematic-review search strategy across Embase, Cochrane, etc.
95
+ - Search quality depends on your query syntax (MeSH, field tags); PubMed may translate it unexpectedly, so check `query_translation`.
96
+ - Open-access coverage varies, and some publishers block automated downloads.
97
+ - Live NCBI, Unpaywall, and PDF download behaviour was not covered by automated tests (they use synthetic fixtures).
98
+
99
+ ## License
100
+ Apache License 2.0, see `LICENSE`. Copyright 2024 ahsanmandhar-ui.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ access.py
4
+ pyproject.toml
5
+ server.py
6
+ pubmed_access_mcp.egg-info/PKG-INFO
7
+ pubmed_access_mcp.egg-info/SOURCES.txt
8
+ pubmed_access_mcp.egg-info/dependency_links.txt
9
+ pubmed_access_mcp.egg-info/entry_points.txt
10
+ pubmed_access_mcp.egg-info/requires.txt
11
+ pubmed_access_mcp.egg-info/top_level.txt
12
+ tests/test_access.py
13
+ tests/test_server.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pubmed-access-mcp = server:main
@@ -0,0 +1,2 @@
1
+ mcp<3,>=1.2.0
2
+ requests>=2.31.0
@@ -0,0 +1,2 @@
1
+ access
2
+ server
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pubmed-access-mcp"
7
+ version = "0.1.0"
8
+ description = "A minimal, auditable Model Context Protocol (MCP) server for PubMed via NCBI E-utilities with open-access detection."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ { name = "ahsanmandhar-ui" }
14
+ ]
15
+ keywords = ["mcp", "pubmed", "ncbi", "open-access", "model-context-protocol", "ai", "research"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Science/Research",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
24
+ ]
25
+ dependencies = [
26
+ "mcp>=1.2.0,<3",
27
+ "requests>=2.31.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ pubmed-access-mcp = "server:main"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/ahsanmandhar-ui/pubmed-mcp"
35
+ Repository = "https://github.com/ahsanmandhar-ui/pubmed-mcp"
36
+ Issues = "https://github.com/ahsanmandhar-ui/pubmed-mcp/issues"
37
+
38
+ [tool.setuptools]
39
+ py-modules = ["server", "access"]
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env python3
2
+ """PubMed MCP server — NCBI E-utilities (esearch + efetch) over stdio.
3
+
4
+ Design guarantees
5
+ -----------------
6
+ * Every field comes from the NCBI response. Nothing is inferred, guessed or paraphrased.
7
+ * A field PubMed does not provide is returned as the literal string MISSING
8
+ ("Data not provided in PubMed abstract").
9
+ * PMIDs that were requested but not returned by NCBI are listed in `not_found`, never fabricated.
10
+ * stdout is reserved for the MCP protocol; all logging goes to stderr.
11
+
12
+ Env (all optional): NCBI_API_KEY, NCBI_EMAIL, NCBI_TOOL
13
+ """
14
+ import datetime
15
+ import logging
16
+ import os
17
+ import re
18
+ import sys
19
+ import threading
20
+ import time
21
+ import xml.etree.ElementTree as ET
22
+ from typing import List, Optional
23
+
24
+ import requests
25
+ import access
26
+ try: # mcp 1.x
27
+ from mcp.server.fastmcp import FastMCP
28
+ except ModuleNotFoundError: # mcp 2.x renamed FastMCP -> MCPServer
29
+ from mcp.server.mcpserver import MCPServer as FastMCP
30
+
31
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
32
+ log = logging.getLogger("pubmed-mcp")
33
+
34
+ EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
35
+ MISSING = "Data not provided in PubMed abstract"
36
+ MAX_SEARCH = 200
37
+ MAX_FETCH = 200
38
+ PMID_RE = re.compile(r"^\d{1,9}$")
39
+
40
+ mcp = FastMCP(
41
+ "pubmed-scraper",
42
+ instructions=(
43
+ "Search PubMed and fetch exact records via NCBI E-utilities. Report only what the tools return. "
44
+ f"If a field equals '{MISSING}', say so; never infer or fill in missing text. "
45
+ "Do not assign evidence levels from the tool output alone: use publication_types plus the full text."
46
+ ),
47
+ )
48
+
49
+
50
+ # ----------------------------------------------------------------- config / HTTP
51
+ def _env(name: str) -> Optional[str]:
52
+ v = os.environ.get(name, "").strip()
53
+ if not v or v.upper().startswith("YOUR_") or v.startswith("${"):
54
+ return None # ignore unfilled placeholders instead of sending a bad key to NCBI
55
+ return v
56
+
57
+
58
+ _lock = threading.Lock()
59
+ _last_call = 0.0
60
+
61
+
62
+ def _throttle() -> None:
63
+ """NCBI limits: 3 requests/s without an API key, 10/s with one."""
64
+ global _last_call
65
+ gap = 0.11 if _env("NCBI_API_KEY") else 0.34
66
+ with _lock:
67
+ wait = _last_call + gap - time.monotonic()
68
+ if wait > 0:
69
+ time.sleep(wait)
70
+ _last_call = time.monotonic()
71
+
72
+
73
+ def _request(endpoint: str, params: dict, method: str = "GET") -> requests.Response:
74
+ params = {**params, "tool": _env("NCBI_TOOL") or "pubmed-mcp"}
75
+ if _env("NCBI_EMAIL"):
76
+ params["email"] = _env("NCBI_EMAIL")
77
+ if _env("NCBI_API_KEY"):
78
+ params["api_key"] = _env("NCBI_API_KEY")
79
+ url = f"{EUTILS}/{endpoint}"
80
+ last = "unknown error"
81
+ for attempt in range(4):
82
+ _throttle()
83
+ try:
84
+ r = requests.post(url, data=params, timeout=45) if method == "POST" else requests.get(url, params=params, timeout=45)
85
+ if r.status_code == 200:
86
+ return r
87
+ last = f"HTTP {r.status_code}"
88
+ if r.status_code not in (429, 500, 502, 503, 504):
89
+ break
90
+ except requests.RequestException as e:
91
+ last = f"{type(e).__name__}: {e}"
92
+ time.sleep(1.5 * (attempt + 1))
93
+ raise RuntimeError(f"NCBI E-utilities request to {endpoint} failed: {last}")
94
+
95
+
96
+ # ----------------------------------------------------------------- parsing (pure functions, unit-tested offline)
97
+ def _text(el: Optional[ET.Element]) -> str:
98
+ if el is None:
99
+ return MISSING
100
+ t = re.sub(r"\s+", " ", "".join(el.itertext())).strip()
101
+ return t or MISSING
102
+
103
+
104
+ def _authors(art: ET.Element):
105
+ out = []
106
+ for a in art.findall("AuthorList/Author"):
107
+ coll = a.findtext("CollectiveName")
108
+ if coll:
109
+ out.append(coll.strip())
110
+ continue
111
+ last, fore = a.findtext("LastName"), a.findtext("ForeName") or a.findtext("Initials")
112
+ if last:
113
+ out.append(f"{last.strip()} {fore.strip()}" if fore else last.strip())
114
+ return out or MISSING
115
+
116
+
117
+ def _abstract(art: ET.Element) -> str:
118
+ parts = []
119
+ for el in art.findall("Abstract/AbstractText"):
120
+ body = re.sub(r"\s+", " ", "".join(el.itertext())).strip()
121
+ if not body:
122
+ continue
123
+ label = el.get("Label")
124
+ parts.append(f"{label}: {body}" if label else body)
125
+ return "\n".join(parts) if parts else MISSING
126
+
127
+
128
+ def _pub_date(art: ET.Element) -> str:
129
+ pd = art.find("Journal/JournalIssue/PubDate")
130
+ if pd is None:
131
+ return MISSING
132
+ bits = [pd.findtext(k) for k in ("Year", "Month", "Day")]
133
+ bits = [b for b in bits if b]
134
+ return " ".join(bits) if bits else (pd.findtext("MedlineDate") or MISSING)
135
+
136
+
137
+ def parse_article(node: ET.Element) -> dict:
138
+ cit = node.find("MedlineCitation")
139
+ art = cit.find("Article") if cit is not None else None
140
+ if cit is None or art is None:
141
+ return {}
142
+ pmid = (cit.findtext("PMID") or "").strip()
143
+ doi, pmcid = MISSING, MISSING
144
+ for aid in node.findall("PubmedData/ArticleIdList/ArticleId"):
145
+ if aid.get("IdType") == "doi" and (aid.text or "").strip():
146
+ doi = aid.text.strip()
147
+ elif aid.get("IdType") == "pmc" and (aid.text or "").strip():
148
+ pmcid = aid.text.strip()
149
+ ptypes = [t.text.strip() for t in art.findall("PublicationTypeList/PublicationType") if t.text and t.text.strip()]
150
+ retraction = any(c.get("RefType") == "RetractionIn" for c in cit.findall("CommentsCorrectionsList/CommentsCorrections"))
151
+ return {
152
+ "pmid": pmid,
153
+ "title": _text(art.find("ArticleTitle")),
154
+ "authors": _authors(art),
155
+ "journal": _text(art.find("Journal/Title")),
156
+ "pub_date": _pub_date(art),
157
+ "doi": doi,
158
+ "pmcid": pmcid,
159
+ "publication_types": ptypes or MISSING,
160
+ "has_retraction_notice": retraction, # True only if NCBI lists a 'RetractionIn' link for this record
161
+ "abstract": _abstract(art),
162
+ "url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
163
+ }
164
+
165
+
166
+ def parse_efetch(xml_text: str, requested: List[str]) -> dict:
167
+ root = ET.fromstring(xml_text)
168
+ records = [r for r in (parse_article(n) for n in root.findall("PubmedArticle")) if r]
169
+ got = {r["pmid"] for r in records}
170
+ return {"requested": len(requested), "returned": len(records), "records": records,
171
+ "not_found": [p for p in requested if p not in got]}
172
+
173
+
174
+ # ----------------------------------------------------------------- tools
175
+ @mcp.tool()
176
+ def search_pubmed(query: str, max_results: int = 20, sort: str = "relevance",
177
+ date_from: Optional[int] = None, date_to: Optional[int] = None) -> dict:
178
+ """Search PubMed (NCBI esearch) and return PMIDs exactly as NCBI returns them.
179
+
180
+ Args:
181
+ query: PubMed query; supports field tags, MeSH and Boolean, e.g.
182
+ '"hypertrophic cardiomyopathy"[MeSH] AND mavacamten[tiab] AND randomized controlled trial[pt]'.
183
+ max_results: 1-200 (default 20). `total_matches` reports the full hit count.
184
+ sort: 'relevance' or 'pub_date' (newest first).
185
+ date_from / date_to: optional publication-year bounds (e.g. 2018, 2025).
186
+ Returns pmids, total_matches and query_translation (how PubMed interpreted the query; log it for reproducibility).
187
+ """
188
+ q = (query or "").strip()
189
+ if not q:
190
+ raise ValueError("query must be non-empty")
191
+ if sort not in ("relevance", "pub_date"):
192
+ raise ValueError("sort must be 'relevance' or 'pub_date'")
193
+ n = max(1, min(int(max_results), MAX_SEARCH))
194
+ params = {"db": "pubmed", "term": q, "retmax": n, "retmode": "json", "sort": sort}
195
+ if date_from or date_to:
196
+ params.update({"datetype": "pdat", "mindate": str(date_from or 1800),
197
+ "maxdate": str(date_to or datetime.date.today().year + 1)})
198
+ res = _request("esearch.fcgi", params).json().get("esearchresult", {})
199
+ if "ERROR" in res:
200
+ raise RuntimeError(f"NCBI esearch error: {res['ERROR']}")
201
+ return {
202
+ "query_submitted": q,
203
+ "query_translation": res.get("querytranslation") or MISSING,
204
+ "total_matches": int(res.get("count", 0)),
205
+ "max_results_applied": n,
206
+ "returned": len(res.get("idlist", [])),
207
+ "pmids": res.get("idlist", []),
208
+ "warnings": (res.get("warninglist") or {}).get("outputmessage", []) + (res.get("errorlist") or {}).get("phrasesnotfound", []),
209
+ }
210
+
211
+
212
+ @mcp.tool()
213
+ def fetch_abstracts(pmids: List[str]) -> dict:
214
+ """Fetch exact PubMed records (NCBI efetch) for up to 200 PMIDs.
215
+
216
+ Per record: pmid, title, authors, journal, pub_date, doi, publication_types, has_retraction_notice,
217
+ abstract (structured abstracts keep their section labels), url.
218
+ Any field PubMed does not provide is the literal string 'Data not provided in PubMed abstract'.
219
+ PMIDs NCBI did not return are listed in `not_found`. Never infer or complete missing text.
220
+ """
221
+ seen, ids = set(), []
222
+ for p in pmids or []:
223
+ s = str(p).strip()
224
+ if not PMID_RE.match(s):
225
+ raise ValueError(f"invalid PMID {p!r}: PMIDs are 1-9 digits")
226
+ if s not in seen:
227
+ seen.add(s)
228
+ ids.append(s)
229
+ if not ids:
230
+ raise ValueError("provide at least one PMID")
231
+ if len(ids) > MAX_FETCH:
232
+ raise ValueError(f"max {MAX_FETCH} PMIDs per call (got {len(ids)}); split the request")
233
+ r = _request("efetch.fcgi", {"db": "pubmed", "id": ",".join(ids), "retmode": "xml", "rettype": "abstract"}, method="POST")
234
+ return parse_efetch(r.text, ids)
235
+
236
+
237
+ @mcp.tool()
238
+ def check_access(pmids: List[str]) -> dict:
239
+ """Classify each PMID as open-access PDF, open-access landing page only, no open access found (likely paywalled), or unchecked.
240
+
241
+ Uses DOI -> Unpaywall and PMCID -> PubMed Central. 'no_open_access_found' means no legal free copy is indexed;
242
+ it does not prove the paper cannot be reached through an institution. Max 100 PMIDs per call.
243
+ Needs UNPAYWALL_EMAIL (or NCBI_EMAIL) for the Unpaywall lookup; without it only PubMed Central links are found.
244
+ """
245
+ ids = [str(p).strip() for p in (pmids or [])]
246
+ if len(set(ids)) > 100:
247
+ raise ValueError("max 100 PMIDs per call; split the request")
248
+ data = fetch_abstracts(ids)
249
+ email = access.unpaywall_email()
250
+ out = access.summarize(access.check_records(data["records"], email))
251
+ out.update({"requested": data["requested"], "not_found": data["not_found"], "unpaywall_email_configured": bool(email)})
252
+ return out
253
+
254
+
255
+ @mcp.tool()
256
+ def search_with_access(query: str, max_results: int = 20, sort: str = "relevance",
257
+ date_from: Optional[int] = None, date_to: Optional[int] = None) -> dict:
258
+ """Run a PubMed search, then report for every hit whether a legal open-access PDF exists or the paper looks paywalled.
259
+
260
+ max_results is capped at 100 here. Returns query_translation and total_matches (as search_pubmed) plus
261
+ counts and four lists: open_access_pdf, open_access_landing_page_only, no_open_access_found, unchecked.
262
+ """
263
+ s = search_pubmed(query, min(int(max_results), 100), sort, date_from, date_to)
264
+ head = {"query_submitted": s["query_submitted"], "query_translation": s["query_translation"],
265
+ "total_matches": s["total_matches"], "returned": s["returned"], "warnings": s["warnings"]}
266
+ if not s["pmids"]:
267
+ return {**head, **access.summarize([])}
268
+ return {**head, **check_access(s["pmids"])}
269
+
270
+
271
+ @mcp.tool()
272
+ def download_pdfs(pmids: List[str], folder: Optional[str] = None) -> dict:
273
+ """Download open-access PDFs for the given PMIDs (max 50) as PMID<id>.pdf. Paywalled papers are skipped, never bypassed.
274
+
275
+ folder: absolute path, or a sub-folder name under PUBMED_PDF_DIR (default ~/pubmed_pdfs). Every file is verified
276
+ to be a real PDF (starts with %PDF); HTML login/bot-check pages are rejected and reported as failed.
277
+ """
278
+ ids = [str(p).strip() for p in (pmids or [])]
279
+ if len(set(ids)) > 50:
280
+ raise ValueError("max 50 PMIDs per call; split the request")
281
+ data = fetch_abstracts(ids)
282
+ results = access.check_records(data["records"], access.unpaywall_email())
283
+ dest = access.target_dir(folder)
284
+ rows = access.download_many(results, dest)
285
+ counts = {}
286
+ for r in rows:
287
+ counts[r["status"]] = counts.get(r["status"], 0) + 1
288
+ return {"folder": str(dest), "counts": counts, "results": rows, "not_found": data["not_found"]}
289
+
290
+
291
+ def main():
292
+ mcp.run() # stdio transport
293
+
294
+
295
+ if __name__ == "__main__":
296
+ main()
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,77 @@
1
+ """Offline tests for access.py. All HTTP responses and records below are SYNTHETIC stand-ins, not real data."""
2
+ import os, sys, tempfile
3
+ from pathlib import Path
4
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
5
+ import access as a
6
+ import server as s
7
+
8
+ M = a.MISSING
9
+
10
+ class Resp:
11
+ def __init__(self, status=200, js=None, chunks=(), headers=None):
12
+ self.status_code, self._js, self._chunks, self.headers = status, js, chunks, headers or {}
13
+ def json(self): return self._js
14
+ def iter_content(self, n): return iter(self._chunks)
15
+ def close(self): pass
16
+
17
+ def rec(pmid, doi=M, pmcid=M): return {"pmid": pmid, "title": f"T{pmid}", "pub_date": "2024", "doi": doi, "pmcid": pmcid, "has_retraction_notice": False}
18
+
19
+ UNPAYWALL = {
20
+ "10.1/oa": {"is_oa": True, "oa_status": "gold", "best_oa_location": {"url_for_pdf": "https://pub.example/a.pdf", "url": "https://pub.example/a", "host_type": "publisher", "license": "cc-by", "version": "publishedVersion"}, "oa_locations": []},
21
+ "10.1/land": {"is_oa": True, "oa_status": "green", "best_oa_location": {"url_for_pdf": None, "url": "https://repo.example/rec/1", "host_type": "repository"}, "oa_locations": []},
22
+ "10.1/closed": {"is_oa": False, "oa_status": "closed", "best_oa_location": None, "oa_locations": []},
23
+ }
24
+ def fake_unpaywall(url, params=None, **k):
25
+ doi = url.split("/v2/")[1]
26
+ return Resp(200, UNPAYWALL[doi]) if doi in UNPAYWALL else Resp(404)
27
+
28
+ def test_classification():
29
+ r = {x["pmid"]: x for x in a.check_records(
30
+ [rec("1", "10.1/oa"), rec("2", "10.1/land"), rec("3", "10.1/closed"), rec("4"), rec("5", pmcid="PMC99"), rec("6", "10.1/nope")],
31
+ "me@example.org", fake_unpaywall, delay=0)}
32
+ assert r["1"]["access"] == a.OPEN_PDF and r["1"]["pdf_candidates"][0]["url"] == "https://pub.example/a.pdf"
33
+ assert r["2"]["access"] == a.OPEN_LANDING and r["2"]["landing_url"] == "https://repo.example/rec/1"
34
+ assert r["3"]["access"] == a.NO_OA and "institution" in r["3"]["note"]
35
+ assert r["4"]["access"] == a.UNCHECKED and "No DOI or PMCID" in r["4"]["note"]
36
+ assert r["5"]["access"] == a.OPEN_PDF and r["5"]["pdf_candidates"][0]["source"] == "PubMed Central"
37
+ assert r["6"]["access"] == a.UNCHECKED and "not found" in r["6"]["note"]
38
+
39
+ def test_no_email_degrades_honestly():
40
+ r = a.classify(rec("7", "10.1/oa"), None, fake_unpaywall)
41
+ assert r["access"] == a.UNCHECKED and "UNPAYWALL_EMAIL" in r["note"]
42
+
43
+ def test_summary_groups():
44
+ sm = a.summarize(a.check_records([rec("1", "10.1/oa"), rec("3", "10.1/closed")], "me@example.org", fake_unpaywall, delay=0))
45
+ assert sm["counts"][a.OPEN_PDF] == 1 and sm["counts"][a.NO_OA] == 1
46
+ assert sm["open_access_pdf"][0]["pdf_url"] == "https://pub.example/a.pdf"
47
+ assert sm["no_open_access_found"][0]["pmid"] == "3"
48
+
49
+ def test_pmcid_parsed_from_pubmed_xml():
50
+ xml = ('<PubmedArticleSet><PubmedArticle><MedlineCitation><PMID>5</PMID><Article><ArticleTitle>x</ArticleTitle></Article></MedlineCitation>'
51
+ '<PubmedData><ArticleIdList><ArticleId IdType="pmc">PMC123</ArticleId></ArticleIdList></PubmedData></PubmedArticle></PubmedArticleSet>')
52
+ assert s.parse_efetch(xml, ["5"])["records"][0]["pmcid"] == "PMC123"
53
+
54
+ def test_download_validates_pdf_and_folder_rules():
55
+ d = Path(tempfile.mkdtemp())
56
+ res = [a.classify(rec("1", "10.1/oa"), "me@example.org", fake_unpaywall)]
57
+ ok = lambda url, **k: Resp(200, chunks=[b"%PDF-1.7 ...", b"more"])
58
+ row = a.download_many(res, d, ok, delay=0)[0]
59
+ assert row["status"] == "downloaded" and (d / "PMID1.pdf").read_bytes().startswith(b"%PDF")
60
+ assert a.download_many(res, d, ok, delay=0)[0]["status"] == "already_exists"
61
+ d2 = Path(tempfile.mkdtemp())
62
+ html = lambda url, **k: Resp(200, chunks=[b"<html>bot check</html>"])
63
+ row = a.download_many(res, d2, html, delay=0)[0]
64
+ assert row["status"] == "failed" and "not a PDF" in row["reason"][0] and not list(d2.iterdir())
65
+ skipped = a.download_many([a.classify(rec("3", "10.1/closed"), "me@example.org", fake_unpaywall)], d2, ok, delay=0)[0]
66
+ assert skipped["status"] == "skipped"
67
+ try: a.target_dir("../escape"); raise AssertionError("traversal allowed")
68
+ except ValueError: pass
69
+
70
+ def test_unsafe_urls_rejected():
71
+ for u in ("file:///etc/passwd", "http://127.0.0.1/x.pdf", "http://localhost/x", "ftp://x/y.pdf", "http://192.168.1.5/a.pdf"):
72
+ assert not a._safe_url(u), u
73
+ assert a._safe_url("https://example.org/a.pdf")
74
+
75
+ if __name__ == "__main__":
76
+ for n, f in list(globals().items()):
77
+ if n.startswith("test_"): f(); print("ok", n)
@@ -0,0 +1,58 @@
1
+ """Offline tests. The XML below is a SYNTHETIC fixture shaped like NCBI's PubmedArticleSet (not a real record)."""
2
+ import os, sys
3
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
4
+ import server as s
5
+
6
+ XML = """<?xml version="1.0"?><PubmedArticleSet>
7
+ <PubmedArticle><MedlineCitation><PMID Version="1">111</PMID><Article>
8
+ <Journal><JournalIssue><PubDate><Year>2024</Year><Month>Mar</Month></PubDate></JournalIssue><Title>Synthetic Journal</Title></Journal>
9
+ <ArticleTitle>A <i>synthetic</i> trial of X.</ArticleTitle>
10
+ <Abstract><AbstractText Label="BACKGROUND">Bg text.</AbstractText><AbstractText Label="RESULTS">n = 10; p &lt; 0.05.</AbstractText></Abstract>
11
+ <AuthorList><Author><LastName>Doe</LastName><ForeName>Jane</ForeName></Author><Author><CollectiveName>Test Group</CollectiveName></Author></AuthorList>
12
+ <PublicationTypeList><PublicationType>Randomized Controlled Trial</PublicationType></PublicationTypeList>
13
+ </Article><CommentsCorrectionsList><CommentsCorrections RefType="RetractionIn"><PMID>999</PMID></CommentsCorrections></CommentsCorrectionsList></MedlineCitation>
14
+ <PubmedData><ArticleIdList><ArticleId IdType="pubmed">111</ArticleId><ArticleId IdType="doi">10.0000/synthetic</ArticleId></ArticleIdList></PubmedData></PubmedArticle>
15
+ <PubmedArticle><MedlineCitation><PMID Version="1">222</PMID><Article>
16
+ <Journal><JournalIssue><PubDate><MedlineDate>2020 Jan-Feb</MedlineDate></PubDate></JournalIssue><Title>Other Journal</Title></Journal>
17
+ <ArticleTitle>No abstract record.</ArticleTitle></Article></MedlineCitation><PubmedData/></PubmedArticle>
18
+ </PubmedArticleSet>"""
19
+
20
+ def test_full_record():
21
+ out = s.parse_efetch(XML, ["111", "222", "333"])
22
+ r = out["records"][0]
23
+ assert r["title"] == "A synthetic trial of X."
24
+ assert r["abstract"] == "BACKGROUND: Bg text.\nRESULTS: n = 10; p < 0.05."
25
+ assert r["authors"] == ["Doe Jane", "Test Group"]
26
+ assert r["doi"] == "10.0000/synthetic" and r["pub_date"] == "2024 Mar"
27
+ assert r["publication_types"] == ["Randomized Controlled Trial"]
28
+ assert r["has_retraction_notice"] is True
29
+
30
+ def test_missing_fields_are_flagged_not_invented():
31
+ r = s.parse_efetch(XML, ["222"])["records"][1]
32
+ for k in ("abstract", "authors", "doi", "publication_types"):
33
+ assert r[k] == s.MISSING, k
34
+ assert r["pub_date"] == "2020 Jan-Feb" and r["has_retraction_notice"] is False
35
+
36
+ def test_not_found_reported():
37
+ assert s.parse_efetch(XML, ["111", "222", "333"])["not_found"] == ["333"]
38
+
39
+ def test_input_validation(monkeypatch=None):
40
+ for bad in (["abc"], [], ["1; DROP"], ["1" * 12]):
41
+ try: s.fetch_abstracts(bad)
42
+ except ValueError: continue
43
+ raise AssertionError(bad)
44
+
45
+ def test_search_uses_ncbi_fields_only():
46
+ class R: # stand-in for the HTTP response
47
+ def json(self): return {"esearchresult": {"count": "57", "idlist": ["1", "2"], "querytranslation": "x[All Fields]"}}
48
+ s._request = lambda *a, **k: R()
49
+ out = s.search_pubmed("x", max_results=999)
50
+ assert out["total_matches"] == 57 and out["pmids"] == ["1", "2"] and out["max_results_applied"] == 200
51
+
52
+ def test_placeholder_key_ignored():
53
+ os.environ["NCBI_API_KEY"] = "YOUR_OPTIONAL_FREE_KEY"
54
+ assert s._env("NCBI_API_KEY") is None
55
+
56
+ if __name__ == "__main__":
57
+ for n, f in list(globals().items()):
58
+ if n.startswith("test_"): f(); print("ok", n)