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.
@@ -0,0 +1,4 @@
1
+ from .server import mcp
2
+
3
+ def main():
4
+ mcp.run(transport="stdio")
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ crossref-mcp-server = crossref_mcp_server:main
@@ -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.