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.
- pubmed_access_mcp-0.1.0/LICENSE +201 -0
- pubmed_access_mcp-0.1.0/PKG-INFO +100 -0
- pubmed_access_mcp-0.1.0/README.md +76 -0
- pubmed_access_mcp-0.1.0/access.py +203 -0
- pubmed_access_mcp-0.1.0/pubmed_access_mcp.egg-info/PKG-INFO +100 -0
- pubmed_access_mcp-0.1.0/pubmed_access_mcp.egg-info/SOURCES.txt +13 -0
- pubmed_access_mcp-0.1.0/pubmed_access_mcp.egg-info/dependency_links.txt +1 -0
- pubmed_access_mcp-0.1.0/pubmed_access_mcp.egg-info/entry_points.txt +2 -0
- pubmed_access_mcp-0.1.0/pubmed_access_mcp.egg-info/requires.txt +2 -0
- pubmed_access_mcp-0.1.0/pubmed_access_mcp.egg-info/top_level.txt +2 -0
- pubmed_access_mcp-0.1.0/pyproject.toml +39 -0
- pubmed_access_mcp-0.1.0/server.py +296 -0
- pubmed_access_mcp-0.1.0/setup.cfg +4 -0
- pubmed_access_mcp-0.1.0/tests/test_access.py +77 -0
- pubmed_access_mcp-0.1.0/tests/test_server.py +58 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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 < 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)
|