crossref-mcp-server 1.0.0__py3-none-any.whl
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.
- crossref_mcp_server/__init__.py +4 -0
- crossref_mcp_server/server.py +925 -0
- crossref_mcp_server-1.0.0.dist-info/METADATA +101 -0
- crossref_mcp_server-1.0.0.dist-info/RECORD +7 -0
- crossref_mcp_server-1.0.0.dist-info/WHEEL +4 -0
- crossref_mcp_server-1.0.0.dist-info/entry_points.txt +2 -0
- crossref_mcp_server-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CrossRef MCP Server for Claude Desktop
|
|
4
|
+
=======================================
|
|
5
|
+
A comprehensive Model Context Protocol server for searching and retrieving
|
|
6
|
+
scholarly metadata from the CrossRef REST API.
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Keyword, title, author, and DOI-based search
|
|
10
|
+
- Journal lookup and journal-specific work retrieval
|
|
11
|
+
- Funder search
|
|
12
|
+
- Date and type filtering
|
|
13
|
+
- Reference list retrieval
|
|
14
|
+
- RIS and BibTeX export for Zotero integration
|
|
15
|
+
|
|
16
|
+
CrossRef API: https://api.crossref.org/
|
|
17
|
+
No API key required. Uses "polite" pool with mailto for better rate limits.
|
|
18
|
+
|
|
19
|
+
Usage with Claude Desktop - add to claude_desktop_config.json:
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"crossref": {
|
|
23
|
+
"command": "python3",
|
|
24
|
+
"args": ["/path/to/crossref_mcp_server.py"],
|
|
25
|
+
"env": {
|
|
26
|
+
"CROSSREF_MAILTO": "your.email@example.com"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
from typing import Optional
|
|
37
|
+
from datetime import datetime
|
|
38
|
+
|
|
39
|
+
import requests
|
|
40
|
+
from mcp.server.fastmcp import FastMCP
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Configuration
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
CROSSREF_BASE = "https://api.crossref.org/v1"
|
|
47
|
+
MAILTO = os.environ.get("CROSSREF_MAILTO", "")
|
|
48
|
+
USER_AGENT = "CrossRefMCPServer/1.0 (https://github.com/crossref-mcp; mailto:{})".format(
|
|
49
|
+
MAILTO or "not-provided"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logging.basicConfig(level=logging.INFO)
|
|
53
|
+
logger = logging.getLogger("crossref-mcp")
|
|
54
|
+
|
|
55
|
+
# Store last search results for export
|
|
56
|
+
_last_results: list[dict] = []
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Helpers
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def _request(endpoint: str, params: Optional[dict] = None) -> dict:
|
|
63
|
+
"""Make a request to the CrossRef API."""
|
|
64
|
+
url = f"{CROSSREF_BASE}/{endpoint}"
|
|
65
|
+
headers = {"User-Agent": USER_AGENT}
|
|
66
|
+
if params is None:
|
|
67
|
+
params = {}
|
|
68
|
+
if MAILTO:
|
|
69
|
+
params["mailto"] = MAILTO
|
|
70
|
+
|
|
71
|
+
resp = requests.get(url, params=params, headers=headers, timeout=30)
|
|
72
|
+
resp.raise_for_status()
|
|
73
|
+
return resp.json()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _format_authors(item: dict) -> str:
|
|
77
|
+
"""Format author list from a CrossRef work item."""
|
|
78
|
+
authors = item.get("author", [])
|
|
79
|
+
if not authors:
|
|
80
|
+
return "Unknown"
|
|
81
|
+
parts = []
|
|
82
|
+
for a in authors[:10]: # Cap at 10 for display
|
|
83
|
+
given = a.get("given", "")
|
|
84
|
+
family = a.get("family", "")
|
|
85
|
+
if family:
|
|
86
|
+
parts.append(f"{family}, {given}".strip(", "))
|
|
87
|
+
elif a.get("name"):
|
|
88
|
+
parts.append(a["name"])
|
|
89
|
+
if len(authors) > 10:
|
|
90
|
+
parts.append("et al.")
|
|
91
|
+
return "; ".join(parts)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_year(item: dict) -> str:
|
|
95
|
+
"""Extract publication year from a CrossRef work item."""
|
|
96
|
+
for date_field in ["published-print", "published-online", "published", "created"]:
|
|
97
|
+
dp = item.get(date_field)
|
|
98
|
+
if dp and "date-parts" in dp and dp["date-parts"]:
|
|
99
|
+
parts = dp["date-parts"][0]
|
|
100
|
+
if parts and parts[0]:
|
|
101
|
+
return str(parts[0])
|
|
102
|
+
return "n.d."
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _extract_date(item: dict) -> str:
|
|
106
|
+
"""Extract full date from a CrossRef work item."""
|
|
107
|
+
for date_field in ["published-print", "published-online", "published", "created"]:
|
|
108
|
+
dp = item.get(date_field)
|
|
109
|
+
if dp and "date-parts" in dp and dp["date-parts"]:
|
|
110
|
+
parts = dp["date-parts"][0]
|
|
111
|
+
if parts:
|
|
112
|
+
date_str = str(parts[0]) if parts[0] else ""
|
|
113
|
+
if len(parts) > 1 and parts[1]:
|
|
114
|
+
date_str += f"-{parts[1]:02d}" if isinstance(parts[1], int) else f"-{parts[1]}"
|
|
115
|
+
if len(parts) > 2 and parts[2]:
|
|
116
|
+
date_str += f"-{parts[2]:02d}" if isinstance(parts[2], int) else f"-{parts[2]}"
|
|
117
|
+
return date_str
|
|
118
|
+
return "n.d."
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _format_work(item: dict, index: int = 0) -> str:
|
|
122
|
+
"""Format a single CrossRef work item for display."""
|
|
123
|
+
title = (item.get("title") or ["No title"])[0]
|
|
124
|
+
authors = _format_authors(item)
|
|
125
|
+
year = _extract_year(item)
|
|
126
|
+
doi = item.get("DOI", "No DOI")
|
|
127
|
+
container = (item.get("container-title") or [""])[0]
|
|
128
|
+
work_type = item.get("type", "unknown")
|
|
129
|
+
volume = item.get("volume", "")
|
|
130
|
+
issue = item.get("issue", "")
|
|
131
|
+
page = item.get("page", "")
|
|
132
|
+
|
|
133
|
+
# Build volume/issue/page string
|
|
134
|
+
vol_str = ""
|
|
135
|
+
if volume:
|
|
136
|
+
vol_str = f"Vol. {volume}"
|
|
137
|
+
if issue:
|
|
138
|
+
vol_str += f"({issue})"
|
|
139
|
+
if page:
|
|
140
|
+
vol_str += f", pp. {page}" if vol_str else f"pp. {page}"
|
|
141
|
+
|
|
142
|
+
# Cited-by count
|
|
143
|
+
cited = item.get("is-referenced-by-count", 0)
|
|
144
|
+
|
|
145
|
+
lines = []
|
|
146
|
+
if index > 0:
|
|
147
|
+
lines.append(f"--- Result {index} ---")
|
|
148
|
+
lines.append(f"Title: {title}")
|
|
149
|
+
lines.append(f"Authors: {authors}")
|
|
150
|
+
lines.append(f"Year: {year}")
|
|
151
|
+
if container:
|
|
152
|
+
lines.append(f"Journal/Source: {container}")
|
|
153
|
+
if vol_str:
|
|
154
|
+
lines.append(f"Volume/Pages: {vol_str}")
|
|
155
|
+
lines.append(f"Type: {work_type}")
|
|
156
|
+
lines.append(f"DOI: {doi}")
|
|
157
|
+
lines.append(f"URL: https://doi.org/{doi}")
|
|
158
|
+
if cited:
|
|
159
|
+
lines.append(f"Cited by: {cited}")
|
|
160
|
+
|
|
161
|
+
# Abstract (if available)
|
|
162
|
+
abstract = item.get("abstract", "")
|
|
163
|
+
if abstract:
|
|
164
|
+
# Strip JATS XML tags
|
|
165
|
+
import re
|
|
166
|
+
clean = re.sub(r"<[^>]+>", "", abstract).strip()
|
|
167
|
+
if len(clean) > 500:
|
|
168
|
+
clean = clean[:500] + "..."
|
|
169
|
+
lines.append(f"Abstract: {clean}")
|
|
170
|
+
|
|
171
|
+
return "\n".join(lines)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _build_filter_string(
|
|
175
|
+
year_from: Optional[str] = None,
|
|
176
|
+
year_to: Optional[str] = None,
|
|
177
|
+
work_type: Optional[str] = None,
|
|
178
|
+
has_abstract: Optional[bool] = None,
|
|
179
|
+
has_orcid: Optional[bool] = None,
|
|
180
|
+
has_references: Optional[bool] = None,
|
|
181
|
+
issn: Optional[str] = None,
|
|
182
|
+
funder: Optional[str] = None,
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Build a CrossRef filter query string."""
|
|
185
|
+
filters = []
|
|
186
|
+
if year_from:
|
|
187
|
+
filters.append(f"from-pub-date:{year_from}")
|
|
188
|
+
if year_to:
|
|
189
|
+
filters.append(f"until-pub-date:{year_to}")
|
|
190
|
+
if work_type:
|
|
191
|
+
filters.append(f"type:{work_type}")
|
|
192
|
+
if has_abstract:
|
|
193
|
+
filters.append("has-abstract:true")
|
|
194
|
+
if has_orcid:
|
|
195
|
+
filters.append("has-orcid:true")
|
|
196
|
+
if has_references:
|
|
197
|
+
filters.append("has-references:true")
|
|
198
|
+
if issn:
|
|
199
|
+
filters.append(f"issn:{issn}")
|
|
200
|
+
if funder:
|
|
201
|
+
filters.append(f"funder:{funder}")
|
|
202
|
+
return ",".join(filters)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _work_to_ris(item: dict) -> str:
|
|
206
|
+
"""Convert a CrossRef work item to RIS format."""
|
|
207
|
+
work_type = item.get("type", "journal-article")
|
|
208
|
+
ris_type_map = {
|
|
209
|
+
"journal-article": "JOUR",
|
|
210
|
+
"book": "BOOK",
|
|
211
|
+
"book-chapter": "CHAP",
|
|
212
|
+
"proceedings-article": "CONF",
|
|
213
|
+
"dissertation": "THES",
|
|
214
|
+
"report": "RPRT",
|
|
215
|
+
"dataset": "DATA",
|
|
216
|
+
"monograph": "BOOK",
|
|
217
|
+
"edited-book": "EDBOOK",
|
|
218
|
+
"reference-book": "BOOK",
|
|
219
|
+
"posted-content": "UNPB",
|
|
220
|
+
}
|
|
221
|
+
ris_type = ris_type_map.get(work_type, "GEN")
|
|
222
|
+
|
|
223
|
+
lines = [f"TY - {ris_type}"]
|
|
224
|
+
|
|
225
|
+
title = (item.get("title") or [""])[0]
|
|
226
|
+
if title:
|
|
227
|
+
lines.append(f"TI - {title}")
|
|
228
|
+
|
|
229
|
+
for author in item.get("author", []):
|
|
230
|
+
given = author.get("given", "")
|
|
231
|
+
family = author.get("family", "")
|
|
232
|
+
if family:
|
|
233
|
+
lines.append(f"AU - {family}, {given}".rstrip(", "))
|
|
234
|
+
|
|
235
|
+
container = (item.get("container-title") or [""])[0]
|
|
236
|
+
if container:
|
|
237
|
+
if work_type == "book-chapter":
|
|
238
|
+
lines.append(f"T2 - {container}")
|
|
239
|
+
else:
|
|
240
|
+
lines.append(f"JO - {container}")
|
|
241
|
+
# Also add abbreviated title if available
|
|
242
|
+
short = (item.get("short-container-title") or [""])[0]
|
|
243
|
+
if short and short != container:
|
|
244
|
+
lines.append(f"JA - {short}")
|
|
245
|
+
|
|
246
|
+
year = _extract_year(item)
|
|
247
|
+
if year != "n.d.":
|
|
248
|
+
lines.append(f"PY - {year}")
|
|
249
|
+
|
|
250
|
+
date = _extract_date(item)
|
|
251
|
+
if date != "n.d.":
|
|
252
|
+
lines.append(f"DA - {date}")
|
|
253
|
+
|
|
254
|
+
volume = item.get("volume", "")
|
|
255
|
+
if volume:
|
|
256
|
+
lines.append(f"VL - {volume}")
|
|
257
|
+
|
|
258
|
+
issue = item.get("issue", "")
|
|
259
|
+
if issue:
|
|
260
|
+
lines.append(f"IS - {issue}")
|
|
261
|
+
|
|
262
|
+
page = item.get("page", "")
|
|
263
|
+
if page:
|
|
264
|
+
if "-" in page:
|
|
265
|
+
sp, ep = page.split("-", 1)
|
|
266
|
+
lines.append(f"SP - {sp.strip()}")
|
|
267
|
+
lines.append(f"EP - {ep.strip()}")
|
|
268
|
+
else:
|
|
269
|
+
lines.append(f"SP - {page}")
|
|
270
|
+
|
|
271
|
+
doi = item.get("DOI", "")
|
|
272
|
+
if doi:
|
|
273
|
+
lines.append(f"DO - {doi}")
|
|
274
|
+
lines.append(f"UR - https://doi.org/{doi}")
|
|
275
|
+
|
|
276
|
+
# Abstract
|
|
277
|
+
abstract = item.get("abstract", "")
|
|
278
|
+
if abstract:
|
|
279
|
+
import re
|
|
280
|
+
clean = re.sub(r"<[^>]+>", "", abstract).strip()
|
|
281
|
+
lines.append(f"AB - {clean}")
|
|
282
|
+
|
|
283
|
+
# ISSNs
|
|
284
|
+
for issn_val in item.get("ISSN", []):
|
|
285
|
+
lines.append(f"SN - {issn_val}")
|
|
286
|
+
|
|
287
|
+
# Publisher
|
|
288
|
+
publisher = item.get("publisher", "")
|
|
289
|
+
if publisher:
|
|
290
|
+
lines.append(f"PB - {publisher}")
|
|
291
|
+
|
|
292
|
+
# Language
|
|
293
|
+
lang = item.get("language", "")
|
|
294
|
+
if lang:
|
|
295
|
+
lines.append(f"LA - {lang}")
|
|
296
|
+
|
|
297
|
+
lines.append("ER - ")
|
|
298
|
+
return "\n".join(lines)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _work_to_bibtex(item: dict) -> str:
|
|
302
|
+
"""Convert a CrossRef work item to BibTeX format."""
|
|
303
|
+
work_type = item.get("type", "journal-article")
|
|
304
|
+
bib_type_map = {
|
|
305
|
+
"journal-article": "article",
|
|
306
|
+
"book": "book",
|
|
307
|
+
"book-chapter": "incollection",
|
|
308
|
+
"proceedings-article": "inproceedings",
|
|
309
|
+
"dissertation": "phdthesis",
|
|
310
|
+
"report": "techreport",
|
|
311
|
+
"dataset": "misc",
|
|
312
|
+
"monograph": "book",
|
|
313
|
+
"posted-content": "unpublished",
|
|
314
|
+
}
|
|
315
|
+
bib_type = bib_type_map.get(work_type, "misc")
|
|
316
|
+
|
|
317
|
+
doi = item.get("DOI", "unknown")
|
|
318
|
+
# Create a citation key
|
|
319
|
+
authors = item.get("author", [])
|
|
320
|
+
first_author = authors[0].get("family", "Unknown") if authors else "Unknown"
|
|
321
|
+
year = _extract_year(item)
|
|
322
|
+
# Clean the key
|
|
323
|
+
key = f"{first_author}{year}".replace(" ", "").replace(",", "")
|
|
324
|
+
|
|
325
|
+
title = (item.get("title") or [""])[0]
|
|
326
|
+
container = (item.get("container-title") or [""])[0]
|
|
327
|
+
volume = item.get("volume", "")
|
|
328
|
+
issue = item.get("issue", "")
|
|
329
|
+
page = item.get("page", "")
|
|
330
|
+
publisher = item.get("publisher", "")
|
|
331
|
+
|
|
332
|
+
# Format authors for BibTeX
|
|
333
|
+
author_list = []
|
|
334
|
+
for a in item.get("author", []):
|
|
335
|
+
given = a.get("given", "")
|
|
336
|
+
family = a.get("family", "")
|
|
337
|
+
if family:
|
|
338
|
+
author_list.append(f"{family}, {given}".rstrip(", "))
|
|
339
|
+
author_str = " and ".join(author_list)
|
|
340
|
+
|
|
341
|
+
lines = [f"@{bib_type}{{{key},"]
|
|
342
|
+
if title:
|
|
343
|
+
lines.append(f" title = {{{title}}},")
|
|
344
|
+
if author_str:
|
|
345
|
+
lines.append(f" author = {{{author_str}}},")
|
|
346
|
+
if container:
|
|
347
|
+
field = "booktitle" if bib_type in ("incollection", "inproceedings") else "journal"
|
|
348
|
+
lines.append(f" {field} = {{{container}}},")
|
|
349
|
+
if year != "n.d.":
|
|
350
|
+
lines.append(f" year = {{{year}}},")
|
|
351
|
+
if volume:
|
|
352
|
+
lines.append(f" volume = {{{volume}}},")
|
|
353
|
+
if issue:
|
|
354
|
+
lines.append(f" number = {{{issue}}},")
|
|
355
|
+
if page:
|
|
356
|
+
lines.append(f" pages = {{{page}}},")
|
|
357
|
+
if publisher:
|
|
358
|
+
lines.append(f" publisher = {{{publisher}}},")
|
|
359
|
+
if doi:
|
|
360
|
+
lines.append(f" doi = {{{doi}}},")
|
|
361
|
+
lines.append(f" url = {{https://doi.org/{doi}}},")
|
|
362
|
+
lines.append("}")
|
|
363
|
+
|
|
364
|
+
return "\n".join(lines)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# MCP Server
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
mcp = FastMCP(
|
|
372
|
+
"CrossRef",
|
|
373
|
+
instructions="Search and retrieve scholarly metadata from CrossRef's database of 150M+ records across all publishers and disciplines.",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@mcp.tool()
|
|
378
|
+
def crossref_search(
|
|
379
|
+
query: str,
|
|
380
|
+
count: int = 25,
|
|
381
|
+
sort: str = "relevance",
|
|
382
|
+
year_from: Optional[str] = None,
|
|
383
|
+
year_to: Optional[str] = None,
|
|
384
|
+
work_type: Optional[str] = None,
|
|
385
|
+
has_abstract: Optional[bool] = None,
|
|
386
|
+
) -> str:
|
|
387
|
+
"""
|
|
388
|
+
Search CrossRef for scholarly works by keyword.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
query: Search terms (searches titles, abstracts, authors, full text)
|
|
392
|
+
count: Number of results (max 50, default 25)
|
|
393
|
+
sort: Sort order - "relevance" (default), "published" (newest first),
|
|
394
|
+
"is-referenced-by-count" (most cited), "references-count"
|
|
395
|
+
year_from: Filter to works published from this year (e.g. "2020")
|
|
396
|
+
year_to: Filter to works published up to this year (e.g. "2025")
|
|
397
|
+
work_type: Filter by type - "journal-article", "book-chapter", "book",
|
|
398
|
+
"proceedings-article", "dissertation", "report", "dataset",
|
|
399
|
+
"posted-content", "monograph"
|
|
400
|
+
has_abstract: If True, only return works with abstracts
|
|
401
|
+
"""
|
|
402
|
+
global _last_results
|
|
403
|
+
|
|
404
|
+
params = {
|
|
405
|
+
"query": query,
|
|
406
|
+
"rows": min(count, 50),
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if sort == "published":
|
|
410
|
+
params["sort"] = "published"
|
|
411
|
+
params["order"] = "desc"
|
|
412
|
+
elif sort == "is-referenced-by-count":
|
|
413
|
+
params["sort"] = "is-referenced-by-count"
|
|
414
|
+
params["order"] = "desc"
|
|
415
|
+
elif sort == "references-count":
|
|
416
|
+
params["sort"] = "references-count"
|
|
417
|
+
params["order"] = "desc"
|
|
418
|
+
|
|
419
|
+
filter_str = _build_filter_string(
|
|
420
|
+
year_from=year_from,
|
|
421
|
+
year_to=year_to,
|
|
422
|
+
work_type=work_type,
|
|
423
|
+
has_abstract=has_abstract,
|
|
424
|
+
)
|
|
425
|
+
if filter_str:
|
|
426
|
+
params["filter"] = filter_str
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
data = _request("works", params)
|
|
430
|
+
msg = data.get("message", {})
|
|
431
|
+
total = msg.get("total-results", 0)
|
|
432
|
+
items = msg.get("items", [])
|
|
433
|
+
|
|
434
|
+
_last_results = items
|
|
435
|
+
|
|
436
|
+
if not items:
|
|
437
|
+
return f"No results found for '{query}'."
|
|
438
|
+
|
|
439
|
+
output = [f"CrossRef Search: '{query}' — {total:,} total results (showing {len(items)})\n"]
|
|
440
|
+
for i, item in enumerate(items, 1):
|
|
441
|
+
output.append(_format_work(item, i))
|
|
442
|
+
output.append("")
|
|
443
|
+
|
|
444
|
+
return "\n".join(output)
|
|
445
|
+
|
|
446
|
+
except requests.HTTPError as e:
|
|
447
|
+
return f"CrossRef API error: {e}"
|
|
448
|
+
except Exception as e:
|
|
449
|
+
return f"Error searching CrossRef: {e}"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@mcp.tool()
|
|
453
|
+
def crossref_title_search(
|
|
454
|
+
title: str,
|
|
455
|
+
count: int = 10,
|
|
456
|
+
year_from: Optional[str] = None,
|
|
457
|
+
year_to: Optional[str] = None,
|
|
458
|
+
) -> str:
|
|
459
|
+
"""
|
|
460
|
+
Search CrossRef specifically by title. More precise than keyword search
|
|
461
|
+
when you know the title or partial title of a work.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
title: Title or partial title to search for
|
|
465
|
+
count: Number of results (max 50, default 10)
|
|
466
|
+
year_from: Filter to works published from this year
|
|
467
|
+
year_to: Filter to works published up to this year
|
|
468
|
+
"""
|
|
469
|
+
global _last_results
|
|
470
|
+
|
|
471
|
+
params = {
|
|
472
|
+
"query.title": title,
|
|
473
|
+
"rows": min(count, 50),
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
filter_str = _build_filter_string(year_from=year_from, year_to=year_to)
|
|
477
|
+
if filter_str:
|
|
478
|
+
params["filter"] = filter_str
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
data = _request("works", params)
|
|
482
|
+
msg = data.get("message", {})
|
|
483
|
+
total = msg.get("total-results", 0)
|
|
484
|
+
items = msg.get("items", [])
|
|
485
|
+
|
|
486
|
+
_last_results = items
|
|
487
|
+
|
|
488
|
+
if not items:
|
|
489
|
+
return f"No results found for title '{title}'."
|
|
490
|
+
|
|
491
|
+
output = [f"CrossRef Title Search: '{title}' — {total:,} total results (showing {len(items)})\n"]
|
|
492
|
+
for i, item in enumerate(items, 1):
|
|
493
|
+
output.append(_format_work(item, i))
|
|
494
|
+
output.append("")
|
|
495
|
+
|
|
496
|
+
return "\n".join(output)
|
|
497
|
+
|
|
498
|
+
except Exception as e:
|
|
499
|
+
return f"Error searching CrossRef: {e}"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@mcp.tool()
|
|
503
|
+
def crossref_author_search(
|
|
504
|
+
author: str,
|
|
505
|
+
query: Optional[str] = None,
|
|
506
|
+
count: int = 25,
|
|
507
|
+
year_from: Optional[str] = None,
|
|
508
|
+
year_to: Optional[str] = None,
|
|
509
|
+
sort: str = "relevance",
|
|
510
|
+
) -> str:
|
|
511
|
+
"""
|
|
512
|
+
Search CrossRef for works by a specific author, optionally combined
|
|
513
|
+
with a topic keyword.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
author: Author name to search (e.g. "Smith" or "Jane Smith")
|
|
517
|
+
query: Optional additional keywords to narrow results
|
|
518
|
+
count: Number of results (max 50, default 25)
|
|
519
|
+
year_from: Filter to works published from this year
|
|
520
|
+
year_to: Filter to works published up to this year
|
|
521
|
+
sort: "relevance" (default), "published", "is-referenced-by-count"
|
|
522
|
+
"""
|
|
523
|
+
global _last_results
|
|
524
|
+
|
|
525
|
+
params = {
|
|
526
|
+
"query.author": author,
|
|
527
|
+
"rows": min(count, 50),
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if query:
|
|
531
|
+
params["query"] = query
|
|
532
|
+
|
|
533
|
+
if sort == "published":
|
|
534
|
+
params["sort"] = "published"
|
|
535
|
+
params["order"] = "desc"
|
|
536
|
+
elif sort == "is-referenced-by-count":
|
|
537
|
+
params["sort"] = "is-referenced-by-count"
|
|
538
|
+
params["order"] = "desc"
|
|
539
|
+
|
|
540
|
+
filter_str = _build_filter_string(year_from=year_from, year_to=year_to)
|
|
541
|
+
if filter_str:
|
|
542
|
+
params["filter"] = filter_str
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
data = _request("works", params)
|
|
546
|
+
msg = data.get("message", {})
|
|
547
|
+
total = msg.get("total-results", 0)
|
|
548
|
+
items = msg.get("items", [])
|
|
549
|
+
|
|
550
|
+
_last_results = items
|
|
551
|
+
|
|
552
|
+
if not items:
|
|
553
|
+
return f"No results found for author '{author}'."
|
|
554
|
+
|
|
555
|
+
label = f"CrossRef Author Search: '{author}'"
|
|
556
|
+
if query:
|
|
557
|
+
label += f" + '{query}'"
|
|
558
|
+
output = [f"{label} — {total:,} total results (showing {len(items)})\n"]
|
|
559
|
+
for i, item in enumerate(items, 1):
|
|
560
|
+
output.append(_format_work(item, i))
|
|
561
|
+
output.append("")
|
|
562
|
+
|
|
563
|
+
return "\n".join(output)
|
|
564
|
+
|
|
565
|
+
except Exception as e:
|
|
566
|
+
return f"Error searching CrossRef: {e}"
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@mcp.tool()
|
|
570
|
+
def crossref_doi_lookup(doi: str) -> str:
|
|
571
|
+
"""
|
|
572
|
+
Retrieve full metadata for a specific work by its DOI.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
doi: The DOI to look up (e.g. "10.1038/nature14539")
|
|
576
|
+
"""
|
|
577
|
+
global _last_results
|
|
578
|
+
|
|
579
|
+
# Clean DOI
|
|
580
|
+
doi = doi.strip()
|
|
581
|
+
if doi.startswith("https://doi.org/"):
|
|
582
|
+
doi = doi[len("https://doi.org/"):]
|
|
583
|
+
elif doi.startswith("http://doi.org/"):
|
|
584
|
+
doi = doi[len("http://doi.org/"):]
|
|
585
|
+
elif doi.startswith("doi:"):
|
|
586
|
+
doi = doi[4:]
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
data = _request(f"works/{doi}")
|
|
590
|
+
item = data.get("message", {})
|
|
591
|
+
|
|
592
|
+
_last_results = [item]
|
|
593
|
+
|
|
594
|
+
lines = [_format_work(item)]
|
|
595
|
+
|
|
596
|
+
# Additional details for single-item lookup
|
|
597
|
+
publisher = item.get("publisher", "")
|
|
598
|
+
if publisher:
|
|
599
|
+
lines.append(f"Publisher: {publisher}")
|
|
600
|
+
|
|
601
|
+
# ISSNs
|
|
602
|
+
issns = item.get("ISSN", [])
|
|
603
|
+
if issns:
|
|
604
|
+
lines.append(f"ISSN: {', '.join(issns)}")
|
|
605
|
+
|
|
606
|
+
# License
|
|
607
|
+
licenses = item.get("license", [])
|
|
608
|
+
if licenses:
|
|
609
|
+
lic_urls = [lic.get("URL", "") for lic in licenses if lic.get("URL")]
|
|
610
|
+
if lic_urls:
|
|
611
|
+
lines.append(f"License: {', '.join(lic_urls)}")
|
|
612
|
+
|
|
613
|
+
# Funders
|
|
614
|
+
funders = item.get("funder", [])
|
|
615
|
+
if funders:
|
|
616
|
+
funder_strs = []
|
|
617
|
+
for f in funders:
|
|
618
|
+
name = f.get("name", "Unknown")
|
|
619
|
+
awards = f.get("award", [])
|
|
620
|
+
if awards:
|
|
621
|
+
funder_strs.append(f"{name} (awards: {', '.join(awards)})")
|
|
622
|
+
else:
|
|
623
|
+
funder_strs.append(name)
|
|
624
|
+
lines.append(f"Funders: {'; '.join(funder_strs)}")
|
|
625
|
+
|
|
626
|
+
# Reference count
|
|
627
|
+
ref_count = item.get("references-count", 0)
|
|
628
|
+
if ref_count:
|
|
629
|
+
lines.append(f"References: {ref_count}")
|
|
630
|
+
|
|
631
|
+
# Subjects
|
|
632
|
+
subjects = item.get("subject", [])
|
|
633
|
+
if subjects:
|
|
634
|
+
lines.append(f"Subjects: {', '.join(subjects)}")
|
|
635
|
+
|
|
636
|
+
return "\n".join(lines)
|
|
637
|
+
|
|
638
|
+
except requests.HTTPError as e:
|
|
639
|
+
if e.response and e.response.status_code == 404:
|
|
640
|
+
return f"DOI not found in CrossRef: {doi}"
|
|
641
|
+
return f"CrossRef API error: {e}"
|
|
642
|
+
except Exception as e:
|
|
643
|
+
return f"Error looking up DOI: {e}"
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@mcp.tool()
|
|
647
|
+
def crossref_journal_search(
|
|
648
|
+
query: str,
|
|
649
|
+
count: int = 20,
|
|
650
|
+
) -> str:
|
|
651
|
+
"""
|
|
652
|
+
Search for journals in the CrossRef database.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
query: Journal name or keywords (e.g. "educational psychology")
|
|
656
|
+
count: Number of results (max 50, default 20)
|
|
657
|
+
"""
|
|
658
|
+
params = {
|
|
659
|
+
"query": query,
|
|
660
|
+
"rows": min(count, 50),
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
try:
|
|
664
|
+
data = _request("journals", params)
|
|
665
|
+
msg = data.get("message", {})
|
|
666
|
+
total = msg.get("total-results", 0)
|
|
667
|
+
items = msg.get("items", [])
|
|
668
|
+
|
|
669
|
+
if not items:
|
|
670
|
+
return f"No journals found for '{query}'."
|
|
671
|
+
|
|
672
|
+
output = [f"CrossRef Journal Search: '{query}' — {total:,} total results (showing {len(items)})\n"]
|
|
673
|
+
for i, item in enumerate(items, 1):
|
|
674
|
+
title = item.get("title", "Unknown")
|
|
675
|
+
publisher = item.get("publisher", "Unknown")
|
|
676
|
+
issns = item.get("ISSN", [])
|
|
677
|
+
subjects = item.get("subjects", [])
|
|
678
|
+
subject_str = ", ".join(s.get("name", "") for s in subjects) if subjects else ""
|
|
679
|
+
counts = item.get("counts", {})
|
|
680
|
+
total_dois = counts.get("total-dois", 0)
|
|
681
|
+
|
|
682
|
+
lines = [f"--- Journal {i} ---"]
|
|
683
|
+
lines.append(f"Title: {title}")
|
|
684
|
+
lines.append(f"Publisher: {publisher}")
|
|
685
|
+
if issns:
|
|
686
|
+
lines.append(f"ISSN: {', '.join(issns)}")
|
|
687
|
+
if subject_str:
|
|
688
|
+
lines.append(f"Subjects: {subject_str}")
|
|
689
|
+
if total_dois:
|
|
690
|
+
lines.append(f"Total DOIs: {total_dois:,}")
|
|
691
|
+
output.append("\n".join(lines))
|
|
692
|
+
output.append("")
|
|
693
|
+
|
|
694
|
+
return "\n".join(output)
|
|
695
|
+
|
|
696
|
+
except Exception as e:
|
|
697
|
+
return f"Error searching journals: {e}"
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@mcp.tool()
|
|
701
|
+
def crossref_journal_works(
|
|
702
|
+
issn: str,
|
|
703
|
+
query: Optional[str] = None,
|
|
704
|
+
count: int = 25,
|
|
705
|
+
sort: str = "published",
|
|
706
|
+
year_from: Optional[str] = None,
|
|
707
|
+
year_to: Optional[str] = None,
|
|
708
|
+
) -> str:
|
|
709
|
+
"""
|
|
710
|
+
Get works published in a specific journal by its ISSN.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
issn: Journal ISSN (e.g. "0028-0836" for Nature)
|
|
714
|
+
query: Optional keywords to filter within the journal
|
|
715
|
+
count: Number of results (max 50, default 25)
|
|
716
|
+
sort: "published" (newest first, default), "relevance", "is-referenced-by-count"
|
|
717
|
+
year_from: Filter to works from this year
|
|
718
|
+
year_to: Filter to works up to this year
|
|
719
|
+
"""
|
|
720
|
+
global _last_results
|
|
721
|
+
|
|
722
|
+
params = {"rows": min(count, 50)}
|
|
723
|
+
|
|
724
|
+
if query:
|
|
725
|
+
params["query"] = query
|
|
726
|
+
|
|
727
|
+
if sort == "published":
|
|
728
|
+
params["sort"] = "published"
|
|
729
|
+
params["order"] = "desc"
|
|
730
|
+
elif sort == "is-referenced-by-count":
|
|
731
|
+
params["sort"] = "is-referenced-by-count"
|
|
732
|
+
params["order"] = "desc"
|
|
733
|
+
|
|
734
|
+
filter_str = _build_filter_string(year_from=year_from, year_to=year_to)
|
|
735
|
+
if filter_str:
|
|
736
|
+
params["filter"] = filter_str
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
data = _request(f"journals/{issn}/works", params)
|
|
740
|
+
msg = data.get("message", {})
|
|
741
|
+
total = msg.get("total-results", 0)
|
|
742
|
+
items = msg.get("items", [])
|
|
743
|
+
|
|
744
|
+
_last_results = items
|
|
745
|
+
|
|
746
|
+
if not items:
|
|
747
|
+
return f"No works found for ISSN {issn}."
|
|
748
|
+
|
|
749
|
+
output = [f"CrossRef Journal Works (ISSN: {issn}) — {total:,} total (showing {len(items)})\n"]
|
|
750
|
+
for i, item in enumerate(items, 1):
|
|
751
|
+
output.append(_format_work(item, i))
|
|
752
|
+
output.append("")
|
|
753
|
+
|
|
754
|
+
return "\n".join(output)
|
|
755
|
+
|
|
756
|
+
except Exception as e:
|
|
757
|
+
return f"Error retrieving journal works: {e}"
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@mcp.tool()
|
|
761
|
+
def crossref_funder_search(
|
|
762
|
+
query: str,
|
|
763
|
+
count: int = 10,
|
|
764
|
+
) -> str:
|
|
765
|
+
"""
|
|
766
|
+
Search for funding organizations in CrossRef.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
query: Funder name or keywords (e.g. "National Science Foundation")
|
|
770
|
+
count: Number of results (max 50, default 10)
|
|
771
|
+
"""
|
|
772
|
+
params = {
|
|
773
|
+
"query": query,
|
|
774
|
+
"rows": min(count, 50),
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
try:
|
|
778
|
+
data = _request("funders", params)
|
|
779
|
+
msg = data.get("message", {})
|
|
780
|
+
total = msg.get("total-results", 0)
|
|
781
|
+
items = msg.get("items", [])
|
|
782
|
+
|
|
783
|
+
if not items:
|
|
784
|
+
return f"No funders found for '{query}'."
|
|
785
|
+
|
|
786
|
+
output = [f"CrossRef Funder Search: '{query}' — {total:,} total (showing {len(items)})\n"]
|
|
787
|
+
for i, item in enumerate(items, 1):
|
|
788
|
+
name = item.get("name", "Unknown")
|
|
789
|
+
funder_id = item.get("id", "")
|
|
790
|
+
location = item.get("location", "")
|
|
791
|
+
alt_names = item.get("alt-names", [])
|
|
792
|
+
work_count = item.get("work-count", 0)
|
|
793
|
+
|
|
794
|
+
lines = [f"--- Funder {i} ---"]
|
|
795
|
+
lines.append(f"Name: {name}")
|
|
796
|
+
if funder_id:
|
|
797
|
+
lines.append(f"Funder DOI: 10.13039/{funder_id}")
|
|
798
|
+
if location:
|
|
799
|
+
lines.append(f"Location: {location}")
|
|
800
|
+
if alt_names:
|
|
801
|
+
lines.append(f"Also known as: {', '.join(alt_names[:5])}")
|
|
802
|
+
if work_count:
|
|
803
|
+
lines.append(f"Funded works: {work_count:,}")
|
|
804
|
+
output.append("\n".join(lines))
|
|
805
|
+
output.append("")
|
|
806
|
+
|
|
807
|
+
return "\n".join(output)
|
|
808
|
+
|
|
809
|
+
except Exception as e:
|
|
810
|
+
return f"Error searching funders: {e}"
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@mcp.tool()
|
|
814
|
+
def crossref_references(doi: str) -> str:
|
|
815
|
+
"""
|
|
816
|
+
Get the reference list for a specific work by DOI.
|
|
817
|
+
Returns the references cited by the given work.
|
|
818
|
+
|
|
819
|
+
Args:
|
|
820
|
+
doi: DOI of the work whose references you want
|
|
821
|
+
"""
|
|
822
|
+
doi = doi.strip()
|
|
823
|
+
if doi.startswith("https://doi.org/"):
|
|
824
|
+
doi = doi[len("https://doi.org/"):]
|
|
825
|
+
|
|
826
|
+
try:
|
|
827
|
+
data = _request(f"works/{doi}")
|
|
828
|
+
item = data.get("message", {})
|
|
829
|
+
references = item.get("reference", [])
|
|
830
|
+
|
|
831
|
+
if not references:
|
|
832
|
+
return f"No references found for DOI {doi} (publisher may not have deposited them)."
|
|
833
|
+
|
|
834
|
+
title = (item.get("title") or ["Unknown"])[0]
|
|
835
|
+
output = [f"References from: {title}\nDOI: {doi}\nTotal references: {len(references)}\n"]
|
|
836
|
+
|
|
837
|
+
for i, ref in enumerate(references, 1):
|
|
838
|
+
ref_doi = ref.get("DOI", "")
|
|
839
|
+
ref_title = ref.get("article-title", "") or ref.get("volume-title", "")
|
|
840
|
+
ref_author = ref.get("author", "")
|
|
841
|
+
ref_year = ref.get("year", "")
|
|
842
|
+
ref_journal = ref.get("journal-title", "")
|
|
843
|
+
unstructured = ref.get("unstructured", "")
|
|
844
|
+
|
|
845
|
+
if unstructured and not ref_title:
|
|
846
|
+
output.append(f"{i}. {unstructured}")
|
|
847
|
+
if ref_doi:
|
|
848
|
+
output.append(f" DOI: {ref_doi}")
|
|
849
|
+
else:
|
|
850
|
+
parts = []
|
|
851
|
+
if ref_author:
|
|
852
|
+
parts.append(ref_author)
|
|
853
|
+
if ref_year:
|
|
854
|
+
parts.append(f"({ref_year})")
|
|
855
|
+
if ref_title:
|
|
856
|
+
parts.append(ref_title)
|
|
857
|
+
if ref_journal:
|
|
858
|
+
parts.append(ref_journal)
|
|
859
|
+
output.append(f"{i}. {' '.join(parts)}")
|
|
860
|
+
if ref_doi:
|
|
861
|
+
output.append(f" DOI: {ref_doi}")
|
|
862
|
+
|
|
863
|
+
output.append("")
|
|
864
|
+
|
|
865
|
+
return "\n".join(output)
|
|
866
|
+
|
|
867
|
+
except requests.HTTPError as e:
|
|
868
|
+
if e.response and e.response.status_code == 404:
|
|
869
|
+
return f"DOI not found: {doi}"
|
|
870
|
+
return f"CrossRef API error: {e}"
|
|
871
|
+
except Exception as e:
|
|
872
|
+
return f"Error retrieving references: {e}"
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
@mcp.tool()
|
|
876
|
+
def crossref_export_ris() -> str:
|
|
877
|
+
"""
|
|
878
|
+
Export the most recent search results as RIS format.
|
|
879
|
+
Import into Zotero: File → Import → paste or save as .ris file.
|
|
880
|
+
"""
|
|
881
|
+
if not _last_results:
|
|
882
|
+
return "No recent search results to export. Run a search first."
|
|
883
|
+
|
|
884
|
+
ris_entries = []
|
|
885
|
+
for item in _last_results:
|
|
886
|
+
ris_entries.append(_work_to_ris(item))
|
|
887
|
+
|
|
888
|
+
count = len(ris_entries)
|
|
889
|
+
output = f"RIS Export ({count} records) — Copy and save as .ris file for Zotero import:\n\n"
|
|
890
|
+
output += "\n\n".join(ris_entries)
|
|
891
|
+
|
|
892
|
+
return output
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
@mcp.tool()
|
|
896
|
+
def crossref_export_bibtex() -> str:
|
|
897
|
+
"""
|
|
898
|
+
Export the most recent search results as BibTeX format.
|
|
899
|
+
"""
|
|
900
|
+
if not _last_results:
|
|
901
|
+
return "No recent search results to export. Run a search first."
|
|
902
|
+
|
|
903
|
+
bib_entries = []
|
|
904
|
+
for item in _last_results:
|
|
905
|
+
bib_entries.append(_work_to_bibtex(item))
|
|
906
|
+
|
|
907
|
+
count = len(bib_entries)
|
|
908
|
+
output = f"BibTeX Export ({count} records):\n\n"
|
|
909
|
+
output += "\n\n".join(bib_entries)
|
|
910
|
+
|
|
911
|
+
return output
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
# ---------------------------------------------------------------------------
|
|
915
|
+
# Entry point
|
|
916
|
+
# ---------------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
if __name__ == "__main__":
|
|
919
|
+
logger.info("Starting CrossRef MCP Server...")
|
|
920
|
+
if MAILTO:
|
|
921
|
+
logger.info(f"Using polite pool with mailto: {MAILTO}")
|
|
922
|
+
else:
|
|
923
|
+
logger.info("No CROSSREF_MAILTO set — using public pool (lower rate limits)")
|
|
924
|
+
logger.info("Set CROSSREF_MAILTO environment variable for better performance")
|
|
925
|
+
mcp.run(transport="stdio")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crossref-mcp-server
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCP server for the Crossref API — search and retrieve scholarly metadata
|
|
5
|
+
Project-URL: Repository, https://github.com/SMABoundless/crossref-mcp-server
|
|
6
|
+
Author: SMABoundless
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: citations,crossref,doi,mcp,scholarly
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: mcp[cli]>=1.0.0
|
|
16
|
+
Requires-Dist: requests>=2.28.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# CrossRef MCP Server
|
|
20
|
+
|
|
21
|
+
An MCP (Model Context Protocol) server for searching and retrieving scholarly metadata from the [CrossRef](https://www.crossref.org/) REST API — 150M+ records across all publishers and disciplines.
|
|
22
|
+
|
|
23
|
+
Built with [FastMCP](https://github.com/modelcontextprotocol/python-sdk). No API key required.
|
|
24
|
+
|
|
25
|
+
## Tools
|
|
26
|
+
|
|
27
|
+
| Tool | Description |
|
|
28
|
+
|------|-------------|
|
|
29
|
+
| `crossref_search` | Search works by keyword with filtering by year, type, and sort options |
|
|
30
|
+
| `crossref_title_search` | Search specifically by title for more precise matching |
|
|
31
|
+
| `crossref_author_search` | Search for works by a specific author, optionally combined with keywords |
|
|
32
|
+
| `crossref_doi_lookup` | Retrieve full metadata for a work by DOI |
|
|
33
|
+
| `crossref_journal_search` | Search for journals by name |
|
|
34
|
+
| `crossref_journal_works` | Get works published in a specific journal by ISSN |
|
|
35
|
+
| `crossref_funder_search` | Search for funding organizations |
|
|
36
|
+
| `crossref_references` | Get the reference list cited by a specific work |
|
|
37
|
+
| `crossref_export_ris` | Export recent results as RIS (for Zotero, EndNote, etc.) |
|
|
38
|
+
| `crossref_export_bibtex` | Export recent results as BibTeX |
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
### 1. Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cd crossref-mcp-server
|
|
46
|
+
python3 -m venv venv
|
|
47
|
+
source venv/bin/activate
|
|
48
|
+
pip install -r requirements.txt
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 2. Configure environment (optional)
|
|
52
|
+
|
|
53
|
+
CrossRef doesn't require an API key, but setting a mailto address gives you access to their faster "polite" API pool:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cp .env.example .env
|
|
57
|
+
# Edit .env with your email address
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3. Add to Claude Desktop
|
|
61
|
+
|
|
62
|
+
Add this to your `claude_desktop_config.json`:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"crossref": {
|
|
68
|
+
"command": "/path/to/crossref-mcp-server/venv/bin/python",
|
|
69
|
+
"args": ["/path/to/crossref-mcp-server/server.py"],
|
|
70
|
+
"env": {
|
|
71
|
+
"CROSSREF_MAILTO": "your.email@example.com"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or if using Claude Code CLI:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
claude mcp add crossref \
|
|
82
|
+
/path/to/crossref-mcp-server/venv/bin/python \
|
|
83
|
+
/path/to/crossref-mcp-server/server.py \
|
|
84
|
+
-e CROSSREF_MAILTO=your.email@example.com
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Usage examples
|
|
88
|
+
|
|
89
|
+
Once connected, you can ask Claude things like:
|
|
90
|
+
|
|
91
|
+
- "Search CrossRef for recent papers on transformer architectures"
|
|
92
|
+
- "Find works by Jane Smith on educational psychology from 2020-2024"
|
|
93
|
+
- "Look up the metadata for DOI 10.1038/nature14539"
|
|
94
|
+
- "Search for journals about machine learning"
|
|
95
|
+
- "Get the reference list for this paper and export as RIS for Zotero"
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
100
|
+
|
|
101
|
+
<!-- mcp-name: io.github.SMABoundless/crossref -->
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
crossref_mcp_server/__init__.py,sha256=opYKBPXWthcwJTmH9kkWspzyPjf60EbjvLGGYRD-T_M,68
|
|
2
|
+
crossref_mcp_server/server.py,sha256=E4iiiLG7qcxdDdzVdEAIlSniVwTADdwJEqOcy3Syg3Q,28577
|
|
3
|
+
crossref_mcp_server-1.0.0.dist-info/METADATA,sha256=4HJaJnWeeTGHOlSXimzCIewz6sndPp0lKwddruem-kM,3176
|
|
4
|
+
crossref_mcp_server-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
crossref_mcp_server-1.0.0.dist-info/entry_points.txt,sha256=UgQxBEfNhdZhCWIaVeJpZWkZN9iW5SwgG-McM3pPJPQ,65
|
|
6
|
+
crossref_mcp_server-1.0.0.dist-info/licenses/LICENSE,sha256=5DP8gnpuq9uW2PXhMtiwMgxehFgbr3d9j8MMnLixAIY,1069
|
|
7
|
+
crossref_mcp_server-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 SMABoundless
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|