orcid-mcp-server 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ ORCID_CLIENT_ID=your-client-id-here
2
+ ORCID_CLIENT_SECRET=your-client-secret-here
@@ -0,0 +1,9 @@
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ .claude/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ src/*.egg-info/
@@ -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.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: orcid-mcp-server
3
+ Version: 1.0.0
4
+ Summary: MCP server for the ORCID API — search researchers, read profiles, and export citations
5
+ Project-URL: Repository, https://github.com/SMABoundless/orcid-mcp-server
6
+ Author: SMABoundless
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: citations,mcp,orcid,researchers,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: httpx>=0.27.0
16
+ Requires-Dist: mcp[cli]>=1.0.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # ORCID MCP Server
20
+
21
+ An MCP (Model Context Protocol) server that provides tools for searching the [ORCID](https://orcid.org) registry, reading researcher profiles, retrieving publications, and exporting citations in RIS and BibTeX formats.
22
+
23
+ Built with [FastMCP](https://github.com/modelcontextprotocol/python-sdk) and the ORCID Public API v3.0.
24
+
25
+ ## Tools
26
+
27
+ | Tool | Description |
28
+ |------|-------------|
29
+ | `orcid_search` | Search for researchers by name, affiliation, keyword, DOI, or advanced Solr query |
30
+ | `orcid_read_record` | Read a researcher's full profile (bio, employment, education, keywords) |
31
+ | `orcid_read_works` | Get publications from a researcher's ORCID record |
32
+ | `orcid_export_ris` | Export retrieved works as RIS (for Zotero, EndNote, etc.) |
33
+ | `orcid_export_bibtex` | Export retrieved works as BibTeX |
34
+
35
+ ## Setup
36
+
37
+ ### 1. Get ORCID API credentials
38
+
39
+ Register for free public API credentials at [ORCID Developer Tools](https://info.orcid.org/documentation/integration-guide/registering-a-public-api-client/).
40
+
41
+ ### 2. Install
42
+
43
+ ```bash
44
+ cd orcid-mcp-server
45
+ python3 -m venv venv
46
+ source venv/bin/activate
47
+ pip install -r requirements.txt
48
+ ```
49
+
50
+ ### 3. Configure environment
51
+
52
+ Copy the example env file and add your credentials:
53
+
54
+ ```bash
55
+ cp .env.example .env
56
+ # Edit .env with your ORCID_CLIENT_ID and ORCID_CLIENT_SECRET
57
+ ```
58
+
59
+ ### 4. Add to Claude Desktop
60
+
61
+ Add this to your `claude_desktop_config.json`:
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "orcid-mcp": {
67
+ "command": "/path/to/orcid-mcp-server/venv/bin/python",
68
+ "args": ["/path/to/orcid-mcp-server/server.py"],
69
+ "env": {
70
+ "ORCID_CLIENT_ID": "your-client-id",
71
+ "ORCID_CLIENT_SECRET": "your-client-secret"
72
+ }
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ Or if using Claude Code CLI:
79
+
80
+ ```bash
81
+ claude mcp add orcid-mcp \
82
+ /path/to/orcid-mcp-server/venv/bin/python \
83
+ /path/to/orcid-mcp-server/server.py \
84
+ -e ORCID_CLIENT_ID=your-client-id \
85
+ -e ORCID_CLIENT_SECRET=your-client-secret
86
+ ```
87
+
88
+ ## Usage examples
89
+
90
+ Once connected, you can ask Claude things like:
91
+
92
+ - "Search ORCID for researchers at Northwestern University working on machine learning"
93
+ - "Look up the ORCID profile for 0000-0002-1825-0097"
94
+ - "Get the publications for this researcher and export them as RIS for Zotero"
95
+
96
+ ## License
97
+
98
+ MIT
99
+
100
+ <!-- mcp-name: io.github.SMABoundless/orcid -->
@@ -0,0 +1,82 @@
1
+ # ORCID MCP Server
2
+
3
+ An MCP (Model Context Protocol) server that provides tools for searching the [ORCID](https://orcid.org) registry, reading researcher profiles, retrieving publications, and exporting citations in RIS and BibTeX formats.
4
+
5
+ Built with [FastMCP](https://github.com/modelcontextprotocol/python-sdk) and the ORCID Public API v3.0.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+ | `orcid_search` | Search for researchers by name, affiliation, keyword, DOI, or advanced Solr query |
12
+ | `orcid_read_record` | Read a researcher's full profile (bio, employment, education, keywords) |
13
+ | `orcid_read_works` | Get publications from a researcher's ORCID record |
14
+ | `orcid_export_ris` | Export retrieved works as RIS (for Zotero, EndNote, etc.) |
15
+ | `orcid_export_bibtex` | Export retrieved works as BibTeX |
16
+
17
+ ## Setup
18
+
19
+ ### 1. Get ORCID API credentials
20
+
21
+ Register for free public API credentials at [ORCID Developer Tools](https://info.orcid.org/documentation/integration-guide/registering-a-public-api-client/).
22
+
23
+ ### 2. Install
24
+
25
+ ```bash
26
+ cd orcid-mcp-server
27
+ python3 -m venv venv
28
+ source venv/bin/activate
29
+ pip install -r requirements.txt
30
+ ```
31
+
32
+ ### 3. Configure environment
33
+
34
+ Copy the example env file and add your credentials:
35
+
36
+ ```bash
37
+ cp .env.example .env
38
+ # Edit .env with your ORCID_CLIENT_ID and ORCID_CLIENT_SECRET
39
+ ```
40
+
41
+ ### 4. Add to Claude Desktop
42
+
43
+ Add this to your `claude_desktop_config.json`:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "orcid-mcp": {
49
+ "command": "/path/to/orcid-mcp-server/venv/bin/python",
50
+ "args": ["/path/to/orcid-mcp-server/server.py"],
51
+ "env": {
52
+ "ORCID_CLIENT_ID": "your-client-id",
53
+ "ORCID_CLIENT_SECRET": "your-client-secret"
54
+ }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ Or if using Claude Code CLI:
61
+
62
+ ```bash
63
+ claude mcp add orcid-mcp \
64
+ /path/to/orcid-mcp-server/venv/bin/python \
65
+ /path/to/orcid-mcp-server/server.py \
66
+ -e ORCID_CLIENT_ID=your-client-id \
67
+ -e ORCID_CLIENT_SECRET=your-client-secret
68
+ ```
69
+
70
+ ## Usage examples
71
+
72
+ Once connected, you can ask Claude things like:
73
+
74
+ - "Search ORCID for researchers at Northwestern University working on machine learning"
75
+ - "Look up the ORCID profile for 0000-0002-1825-0097"
76
+ - "Get the publications for this researcher and export them as RIS for Zotero"
77
+
78
+ ## License
79
+
80
+ MIT
81
+
82
+ <!-- mcp-name: io.github.SMABoundless/orcid -->
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "orcid-mcp-server"
7
+ version = "1.0.0"
8
+ description = "MCP server for the ORCID API — search researchers, read profiles, and export citations"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "SMABoundless" }]
13
+ keywords = ["mcp", "orcid", "scholarly", "researchers", "citations"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ ]
20
+ dependencies = [
21
+ "mcp[cli]>=1.0.0",
22
+ "httpx>=0.27.0",
23
+ ]
24
+
25
+ [project.scripts]
26
+ orcid-mcp-server = "orcid_mcp_server:main"
27
+
28
+ [project.urls]
29
+ Repository = "https://github.com/SMABoundless/orcid-mcp-server"
@@ -0,0 +1,2 @@
1
+ mcp
2
+ httpx
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ORCID MCP Server for Claude Desktop
4
+
5
+ Provides Claude Desktop with tools to search the ORCID registry,
6
+ read researcher profiles, retrieve works, and export citations.
7
+
8
+ ORCID Public API v3.0 docs:
9
+ https://github.com/ORCID/ORCID-Source/blob/main/orcid-api-web/README.md
10
+ """
11
+
12
+ import os
13
+ from typing import Optional
14
+ from mcp.server.fastmcp import FastMCP
15
+ import httpx
16
+
17
+ # ── Configuration ──────────────────────────────────────────────────────────
18
+
19
+ CLIENT_ID = os.environ.get("ORCID_CLIENT_ID", "")
20
+ CLIENT_SECRET = os.environ.get("ORCID_CLIENT_SECRET", "")
21
+ BASE_URL = "https://pub.orcid.org/v3.0"
22
+ TOKEN_URL = "https://orcid.org/oauth/token"
23
+
24
+ mcp = FastMCP("ORCID")
25
+
26
+ # ── Token management ──────────────────────────────────────────────────────
27
+
28
+ _access_token: str = ""
29
+
30
+
31
+ async def _get_token() -> str:
32
+ """Obtain a /read-public access token via client credentials."""
33
+ global _access_token
34
+ if _access_token:
35
+ return _access_token
36
+
37
+ async with httpx.AsyncClient(timeout=15) as client:
38
+ resp = await client.post(
39
+ TOKEN_URL,
40
+ data={
41
+ "client_id": CLIENT_ID,
42
+ "client_secret": CLIENT_SECRET,
43
+ "grant_type": "client_credentials",
44
+ "scope": "/read-public",
45
+ },
46
+ headers={"Accept": "application/json"},
47
+ )
48
+ resp.raise_for_status()
49
+ _access_token = resp.json()["access_token"]
50
+ return _access_token
51
+
52
+
53
+ async def _get(url: str, params: dict = None) -> dict:
54
+ """Make an authenticated GET request to the ORCID API."""
55
+ token = await _get_token()
56
+ async with httpx.AsyncClient(timeout=30) as client:
57
+ resp = await client.get(
58
+ url,
59
+ headers={
60
+ "Authorization": f"Bearer {token}",
61
+ "Accept": "application/json",
62
+ },
63
+ params=params or {},
64
+ )
65
+ resp.raise_for_status()
66
+ return resp.json()
67
+
68
+
69
+ # ── Helpers ────────────────────────────────────────────────────────────────
70
+
71
+ def _format_search_result(i: int, result: dict) -> str:
72
+ """Format an expanded-search result."""
73
+ orcid = result.get("orcid-id", "")
74
+ given = result.get("given-names", "")
75
+ family = result.get("family-names", "")
76
+ credit = result.get("credit-name", "")
77
+ institutions = result.get("institution-name", [])
78
+ if isinstance(institutions, str):
79
+ institutions = [institutions]
80
+
81
+ name = credit if credit else f"{given} {family}".strip()
82
+ line = f"{i}. {name}"
83
+ if orcid:
84
+ line += f"\n ORCID: https://orcid.org/{orcid}"
85
+ if institutions:
86
+ line += f"\n Affiliations: {'; '.join(institutions[:5])}"
87
+ return line
88
+
89
+
90
+ def _format_work(i: int, work_summary: dict) -> str:
91
+ """Format a single work summary from an ORCID record."""
92
+ title_obj = work_summary.get("title", {})
93
+ title = ""
94
+ if title_obj:
95
+ title_val = title_obj.get("title", {})
96
+ title = title_val.get("value", "") if isinstance(title_val, dict) else str(title_val)
97
+
98
+ work_type = work_summary.get("type", "")
99
+ pub_date = work_summary.get("publication-date") or {}
100
+ year = ""
101
+ if pub_date and pub_date.get("year"):
102
+ year = pub_date["year"].get("value", "")
103
+
104
+ journal = work_summary.get("journal-title")
105
+ journal_name = ""
106
+ if journal:
107
+ journal_name = journal.get("value", "") if isinstance(journal, dict) else str(journal)
108
+
109
+ # External identifiers (DOI, etc.)
110
+ ext_ids = work_summary.get("external-ids", {})
111
+ ext_id_list = ext_ids.get("external-id", []) if ext_ids else []
112
+ doi = ""
113
+ for eid in ext_id_list:
114
+ if eid.get("external-id-type") == "doi":
115
+ doi = eid.get("external-id-value", "")
116
+ break
117
+
118
+ line = f"{i}. {title or 'Untitled'}"
119
+ if journal_name:
120
+ line += f"\n Source: {journal_name}"
121
+ if year:
122
+ line += f"\n Year: {year}"
123
+ if work_type:
124
+ line += f"\n Type: {work_type}"
125
+ if doi:
126
+ line += f"\n DOI: https://doi.org/{doi}"
127
+ return line
128
+
129
+
130
+ def _work_to_ris(work_summary: dict) -> str:
131
+ """Convert an ORCID work summary to RIS format."""
132
+ title_obj = work_summary.get("title", {})
133
+ title = ""
134
+ if title_obj:
135
+ title_val = title_obj.get("title", {})
136
+ title = title_val.get("value", "") if isinstance(title_val, dict) else str(title_val)
137
+
138
+ work_type = work_summary.get("type", "")
139
+ ris_type = "JOUR" if "journal" in work_type.lower() else "GEN"
140
+
141
+ pub_date = work_summary.get("publication-date") or {}
142
+ year = ""
143
+ if pub_date and pub_date.get("year"):
144
+ year = pub_date["year"].get("value", "")
145
+
146
+ journal = work_summary.get("journal-title")
147
+ journal_name = ""
148
+ if journal:
149
+ journal_name = journal.get("value", "") if isinstance(journal, dict) else str(journal)
150
+
151
+ ext_ids = work_summary.get("external-ids", {})
152
+ ext_id_list = ext_ids.get("external-id", []) if ext_ids else []
153
+ doi = ""
154
+ for eid in ext_id_list:
155
+ if eid.get("external-id-type") == "doi":
156
+ doi = eid.get("external-id-value", "")
157
+ break
158
+
159
+ lines = [f"TY - {ris_type}"]
160
+ if title:
161
+ lines.append(f"TI - {title}")
162
+ if journal_name:
163
+ lines.append(f"JO - {journal_name}")
164
+ if year:
165
+ lines.append(f"PY - {year}")
166
+ if doi:
167
+ lines.append(f"DO - {doi}")
168
+ lines.append("ER - ")
169
+ return "\n".join(lines)
170
+
171
+
172
+ def _work_to_bibtex(work_summary: dict) -> str:
173
+ """Convert an ORCID work summary to BibTeX format."""
174
+ title_obj = work_summary.get("title", {})
175
+ title = ""
176
+ if title_obj:
177
+ title_val = title_obj.get("title", {})
178
+ title = title_val.get("value", "") if isinstance(title_val, dict) else str(title_val)
179
+
180
+ work_type = work_summary.get("type", "")
181
+ bib_type = "article" if "journal" in work_type.lower() else "misc"
182
+
183
+ pub_date = work_summary.get("publication-date") or {}
184
+ year = ""
185
+ if pub_date and pub_date.get("year"):
186
+ year = pub_date["year"].get("value", "")
187
+
188
+ journal = work_summary.get("journal-title")
189
+ journal_name = ""
190
+ if journal:
191
+ journal_name = journal.get("value", "") if isinstance(journal, dict) else str(journal)
192
+
193
+ ext_ids = work_summary.get("external-ids", {})
194
+ ext_id_list = ext_ids.get("external-id", []) if ext_ids else []
195
+ doi = ""
196
+ for eid in ext_id_list:
197
+ if eid.get("external-id-type") == "doi":
198
+ doi = eid.get("external-id-value", "")
199
+ break
200
+
201
+ key = f"orcid{year or 'nd'}"
202
+ lines = [f"@{bib_type}{{{key},"]
203
+ if title:
204
+ lines.append(f" title = {{{title}}},")
205
+ if journal_name:
206
+ lines.append(f" journal = {{{journal_name}}},")
207
+ if year:
208
+ lines.append(f" year = {{{year}}},")
209
+ if doi:
210
+ lines.append(f" doi = {{{doi}}},")
211
+ lines.append("}")
212
+ return "\n".join(lines)
213
+
214
+
215
+ # ── Store last works results for export ──────────────────────────────────
216
+
217
+ _last_works: list = []
218
+
219
+
220
+ # ── Tools ─────────────────────────────────────────────────────────────────
221
+
222
+ @mcp.tool()
223
+ async def orcid_search(
224
+ query: str,
225
+ search_type: str = "name",
226
+ count: int = 25,
227
+ ) -> str:
228
+ """
229
+ Search the ORCID registry for researchers.
230
+
231
+ Args:
232
+ query: Search terms (name, keyword, affiliation, DOI, ORCID iD)
233
+ search_type: One of: name, affiliation, keyword, doi, advanced
234
+ - name: searches by researcher name
235
+ - affiliation: searches by institution/organization name
236
+ - keyword: searches researcher keywords/biography
237
+ - doi: finds the ORCID record linked to a specific DOI
238
+ - advanced: pass raw Solr query (e.g. "family-name:Einstein AND keyword:Relativity")
239
+ count: Number of results (max 100, default 25)
240
+ """
241
+ query_map = {
242
+ "name": lambda q: f"given-and-family-names:{q}",
243
+ "affiliation": lambda q: f"affiliation-org-name:{q}",
244
+ "keyword": lambda q: f"keyword:{q}",
245
+ "doi": lambda q: f'doi-self:"{q}"',
246
+ "advanced": lambda q: q,
247
+ }
248
+ builder = query_map.get(search_type, query_map["name"])
249
+ solr_query = builder(query)
250
+
251
+ try:
252
+ data = await _get(
253
+ f"{BASE_URL}/expanded-search/",
254
+ {"q": solr_query, "rows": min(count, 100), "start": 0},
255
+ )
256
+
257
+ results = data.get("expanded-result", [])
258
+ total = data.get("num-found", 0)
259
+
260
+ if not results:
261
+ return f"No results found for: {query}"
262
+
263
+ header = f"ORCID Search: {total} total results, showing {len(results)}\n"
264
+ header += f"Query: {solr_query}\n"
265
+ header += "=" * 60 + "\n\n"
266
+ formatted = "\n\n".join(
267
+ _format_search_result(i, r) for i, r in enumerate(results, 1)
268
+ )
269
+ return header + formatted
270
+ except httpx.HTTPStatusError as e:
271
+ return f"ORCID API error: {e.response.status_code} — {e.response.text}"
272
+ except Exception as e:
273
+ return f"Error: {str(e)}"
274
+
275
+
276
+ @mcp.tool()
277
+ async def orcid_read_record(orcid_id: str) -> str:
278
+ """
279
+ Read a researcher's full ORCID profile.
280
+
281
+ Args:
282
+ orcid_id: The ORCID iD (e.g. "0000-0002-1825-0097")
283
+ """
284
+ try:
285
+ data = await _get(f"{BASE_URL}/{orcid_id}/record")
286
+
287
+ # Person details
288
+ person = data.get("person", {})
289
+ name_obj = person.get("name", {})
290
+ given = ""
291
+ family = ""
292
+ credit = ""
293
+ if name_obj:
294
+ gn = name_obj.get("given-names")
295
+ given = gn.get("value", "") if isinstance(gn, dict) else (gn or "")
296
+ fn = name_obj.get("family-name")
297
+ family = fn.get("value", "") if isinstance(fn, dict) else (fn or "")
298
+ cn = name_obj.get("credit-name")
299
+ credit = cn.get("value", "") if isinstance(cn, dict) else (cn or "")
300
+
301
+ bio_obj = person.get("biography")
302
+ bio = ""
303
+ if bio_obj:
304
+ bio = bio_obj.get("content", "") if isinstance(bio_obj, dict) else str(bio_obj)
305
+
306
+ # Keywords
307
+ kw_obj = person.get("keywords", {})
308
+ kw_list = kw_obj.get("keyword", []) if kw_obj else []
309
+ keywords = [k.get("content", "") for k in kw_list if k.get("content")]
310
+
311
+ # Researcher URLs
312
+ urls_obj = person.get("researcher-urls", {})
313
+ url_list = urls_obj.get("researcher-url", []) if urls_obj else []
314
+ urls = [(u.get("url-name", ""), u.get("url", {}).get("value", "")) for u in url_list]
315
+
316
+ # Emails
317
+ emails_obj = person.get("emails", {})
318
+ email_list = emails_obj.get("email", []) if emails_obj else []
319
+ emails = [e.get("email", "") for e in email_list if e.get("email")]
320
+
321
+ # Activities summary
322
+ activities = data.get("activities-summary", {})
323
+
324
+ # Employments
325
+ emp_obj = activities.get("employments", {})
326
+ emp_groups = emp_obj.get("affiliation-group", []) if emp_obj else []
327
+ employments = []
328
+ for group in emp_groups:
329
+ summaries = group.get("summaries", [])
330
+ for s in summaries:
331
+ emp = s.get("employment-summary", {})
332
+ org = emp.get("organization", {})
333
+ org_name = org.get("name", "")
334
+ role = emp.get("role-title", "")
335
+ dept = emp.get("department-name", "")
336
+ start = emp.get("start-date")
337
+ end = emp.get("end-date")
338
+ start_yr = start.get("year", {}).get("value", "") if start else ""
339
+ end_yr = end.get("year", {}).get("value", "present") if end else "present"
340
+ entry = org_name
341
+ if role:
342
+ entry = f"{role}, {entry}"
343
+ if dept:
344
+ entry += f" ({dept})"
345
+ if start_yr:
346
+ entry += f" [{start_yr}–{end_yr}]"
347
+ employments.append(entry)
348
+
349
+ # Educations
350
+ edu_obj = activities.get("educations", {})
351
+ edu_groups = edu_obj.get("affiliation-group", []) if edu_obj else []
352
+ educations = []
353
+ for group in edu_groups:
354
+ summaries = group.get("summaries", [])
355
+ for s in summaries:
356
+ edu = s.get("education-summary", {})
357
+ org = edu.get("organization", {})
358
+ org_name = org.get("name", "")
359
+ role = edu.get("role-title", "")
360
+ dept = edu.get("department-name", "")
361
+ start = edu.get("start-date")
362
+ end = edu.get("end-date")
363
+ start_yr = start.get("year", {}).get("value", "") if start else ""
364
+ end_yr = end.get("year", {}).get("value", "") if end else ""
365
+ entry = org_name
366
+ if role:
367
+ entry = f"{role}, {entry}"
368
+ if dept:
369
+ entry += f" ({dept})"
370
+ if start_yr:
371
+ entry += f" [{start_yr}–{end_yr}]" if end_yr else f" [{start_yr}–]"
372
+ educations.append(entry)
373
+
374
+ # Works count
375
+ works_obj = activities.get("works", {})
376
+ work_groups = works_obj.get("group", []) if works_obj else []
377
+ works_count = len(work_groups)
378
+
379
+ # Fundings count
380
+ fund_obj = activities.get("fundings", {})
381
+ fund_groups = fund_obj.get("group", []) if fund_obj else []
382
+ fundings_count = len(fund_groups)
383
+
384
+ # Build output
385
+ display_name = credit if credit else f"{given} {family}".strip()
386
+ output = f"Name: {display_name}\n"
387
+ output += f"ORCID: https://orcid.org/{orcid_id}\n"
388
+ if emails:
389
+ output += f"Email: {'; '.join(emails)}\n"
390
+ if bio:
391
+ output += f"\nBiography:\n{bio}\n"
392
+ if keywords:
393
+ output += f"\nKeywords: {'; '.join(keywords)}\n"
394
+ if urls:
395
+ output += "\nLinks:\n"
396
+ for name, url in urls:
397
+ output += f" - {name}: {url}\n" if name else f" - {url}\n"
398
+ if employments:
399
+ output += f"\nEmployment ({len(employments)}):\n"
400
+ for emp in employments:
401
+ output += f" - {emp}\n"
402
+ if educations:
403
+ output += f"\nEducation ({len(educations)}):\n"
404
+ for edu in educations:
405
+ output += f" - {edu}\n"
406
+ output += f"\nWorks: {works_count} items"
407
+ if works_count > 0:
408
+ output += " (use orcid_read_works to see them)"
409
+ output += f"\nFunding: {fundings_count} items"
410
+
411
+ return output
412
+ except httpx.HTTPStatusError as e:
413
+ return f"ORCID API error: {e.response.status_code} — {e.response.text}"
414
+ except Exception as e:
415
+ return f"Error: {str(e)}"
416
+
417
+
418
+ @mcp.tool()
419
+ async def orcid_read_works(
420
+ orcid_id: str,
421
+ count: int = 25,
422
+ ) -> str:
423
+ """
424
+ Get publications from an ORCID researcher profile.
425
+
426
+ Args:
427
+ orcid_id: The ORCID iD (e.g. "0000-0002-1825-0097")
428
+ count: Maximum number of works to return (default 25)
429
+ """
430
+ global _last_works
431
+ try:
432
+ data = await _get(f"{BASE_URL}/{orcid_id}/works")
433
+ groups = data.get("group", [])
434
+
435
+ if not groups:
436
+ _last_works = []
437
+ return f"No works found for ORCID {orcid_id}"
438
+
439
+ # Each group has work-summary entries; take the first summary per group
440
+ works = []
441
+ for group in groups[:count]:
442
+ summaries = group.get("work-summary", [])
443
+ if summaries:
444
+ works.append(summaries[0])
445
+
446
+ _last_works = works
447
+
448
+ header = f"Works for ORCID {orcid_id}: {len(groups)} total, showing {len(works)}\n"
449
+ header += "=" * 60 + "\n\n"
450
+ formatted = "\n\n".join(
451
+ _format_work(i, w) for i, w in enumerate(works, 1)
452
+ )
453
+ return header + formatted
454
+ except httpx.HTTPStatusError as e:
455
+ return f"ORCID API error: {e.response.status_code} — {e.response.text}"
456
+ except Exception as e:
457
+ return f"Error: {str(e)}"
458
+
459
+
460
+ @mcp.tool()
461
+ async def orcid_export_ris() -> str:
462
+ """
463
+ Export the most recent orcid_read_works results as RIS format.
464
+ Save output as a .ris file and import into Zotero: File -> Import.
465
+ """
466
+ if not _last_works:
467
+ return "No works to export. Run orcid_read_works first."
468
+ records = [_work_to_ris(w) for w in _last_works]
469
+ count = len(records)
470
+ return f"RIS Export ({count} records) — Save as .ris and import into Zotero:\n\n" + "\n\n".join(records)
471
+
472
+
473
+ @mcp.tool()
474
+ async def orcid_export_bibtex() -> str:
475
+ """
476
+ Export the most recent orcid_read_works results as BibTeX format.
477
+ """
478
+ if not _last_works:
479
+ return "No works to export. Run orcid_read_works first."
480
+ records = [_work_to_bibtex(w) for w in _last_works]
481
+ count = len(records)
482
+ return f"BibTeX Export ({count} records):\n\n" + "\n\n".join(records)
483
+
484
+
485
+ # ── Run ────────────────────────────────────────────────────────────────────
486
+
487
+ if __name__ == "__main__":
488
+ mcp.run()
@@ -0,0 +1,4 @@
1
+ from .server import mcp
2
+
3
+ def main():
4
+ mcp.run()
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ORCID MCP Server for Claude Desktop
4
+
5
+ Provides Claude Desktop with tools to search the ORCID registry,
6
+ read researcher profiles, retrieve works, and export citations.
7
+
8
+ ORCID Public API v3.0 docs:
9
+ https://github.com/ORCID/ORCID-Source/blob/main/orcid-api-web/README.md
10
+ """
11
+
12
+ import os
13
+ from typing import Optional
14
+ from mcp.server.fastmcp import FastMCP
15
+ import httpx
16
+
17
+ # ── Configuration ──────────────────────────────────────────────────────────
18
+
19
+ CLIENT_ID = os.environ.get("ORCID_CLIENT_ID", "")
20
+ CLIENT_SECRET = os.environ.get("ORCID_CLIENT_SECRET", "")
21
+ BASE_URL = "https://pub.orcid.org/v3.0"
22
+ TOKEN_URL = "https://orcid.org/oauth/token"
23
+
24
+ mcp = FastMCP("ORCID")
25
+
26
+ # ── Token management ──────────────────────────────────────────────────────
27
+
28
+ _access_token: str = ""
29
+
30
+
31
+ async def _get_token() -> str:
32
+ """Obtain a /read-public access token via client credentials."""
33
+ global _access_token
34
+ if _access_token:
35
+ return _access_token
36
+
37
+ async with httpx.AsyncClient(timeout=15) as client:
38
+ resp = await client.post(
39
+ TOKEN_URL,
40
+ data={
41
+ "client_id": CLIENT_ID,
42
+ "client_secret": CLIENT_SECRET,
43
+ "grant_type": "client_credentials",
44
+ "scope": "/read-public",
45
+ },
46
+ headers={"Accept": "application/json"},
47
+ )
48
+ resp.raise_for_status()
49
+ _access_token = resp.json()["access_token"]
50
+ return _access_token
51
+
52
+
53
+ async def _get(url: str, params: dict = None) -> dict:
54
+ """Make an authenticated GET request to the ORCID API."""
55
+ token = await _get_token()
56
+ async with httpx.AsyncClient(timeout=30) as client:
57
+ resp = await client.get(
58
+ url,
59
+ headers={
60
+ "Authorization": f"Bearer {token}",
61
+ "Accept": "application/json",
62
+ },
63
+ params=params or {},
64
+ )
65
+ resp.raise_for_status()
66
+ return resp.json()
67
+
68
+
69
+ # ── Helpers ────────────────────────────────────────────────────────────────
70
+
71
+ def _format_search_result(i: int, result: dict) -> str:
72
+ """Format an expanded-search result."""
73
+ orcid = result.get("orcid-id", "")
74
+ given = result.get("given-names", "")
75
+ family = result.get("family-names", "")
76
+ credit = result.get("credit-name", "")
77
+ institutions = result.get("institution-name", [])
78
+ if isinstance(institutions, str):
79
+ institutions = [institutions]
80
+
81
+ name = credit if credit else f"{given} {family}".strip()
82
+ line = f"{i}. {name}"
83
+ if orcid:
84
+ line += f"\n ORCID: https://orcid.org/{orcid}"
85
+ if institutions:
86
+ line += f"\n Affiliations: {'; '.join(institutions[:5])}"
87
+ return line
88
+
89
+
90
+ def _format_work(i: int, work_summary: dict) -> str:
91
+ """Format a single work summary from an ORCID record."""
92
+ title_obj = work_summary.get("title", {})
93
+ title = ""
94
+ if title_obj:
95
+ title_val = title_obj.get("title", {})
96
+ title = title_val.get("value", "") if isinstance(title_val, dict) else str(title_val)
97
+
98
+ work_type = work_summary.get("type", "")
99
+ pub_date = work_summary.get("publication-date") or {}
100
+ year = ""
101
+ if pub_date and pub_date.get("year"):
102
+ year = pub_date["year"].get("value", "")
103
+
104
+ journal = work_summary.get("journal-title")
105
+ journal_name = ""
106
+ if journal:
107
+ journal_name = journal.get("value", "") if isinstance(journal, dict) else str(journal)
108
+
109
+ # External identifiers (DOI, etc.)
110
+ ext_ids = work_summary.get("external-ids", {})
111
+ ext_id_list = ext_ids.get("external-id", []) if ext_ids else []
112
+ doi = ""
113
+ for eid in ext_id_list:
114
+ if eid.get("external-id-type") == "doi":
115
+ doi = eid.get("external-id-value", "")
116
+ break
117
+
118
+ line = f"{i}. {title or 'Untitled'}"
119
+ if journal_name:
120
+ line += f"\n Source: {journal_name}"
121
+ if year:
122
+ line += f"\n Year: {year}"
123
+ if work_type:
124
+ line += f"\n Type: {work_type}"
125
+ if doi:
126
+ line += f"\n DOI: https://doi.org/{doi}"
127
+ return line
128
+
129
+
130
+ def _work_to_ris(work_summary: dict) -> str:
131
+ """Convert an ORCID work summary to RIS format."""
132
+ title_obj = work_summary.get("title", {})
133
+ title = ""
134
+ if title_obj:
135
+ title_val = title_obj.get("title", {})
136
+ title = title_val.get("value", "") if isinstance(title_val, dict) else str(title_val)
137
+
138
+ work_type = work_summary.get("type", "")
139
+ ris_type = "JOUR" if "journal" in work_type.lower() else "GEN"
140
+
141
+ pub_date = work_summary.get("publication-date") or {}
142
+ year = ""
143
+ if pub_date and pub_date.get("year"):
144
+ year = pub_date["year"].get("value", "")
145
+
146
+ journal = work_summary.get("journal-title")
147
+ journal_name = ""
148
+ if journal:
149
+ journal_name = journal.get("value", "") if isinstance(journal, dict) else str(journal)
150
+
151
+ ext_ids = work_summary.get("external-ids", {})
152
+ ext_id_list = ext_ids.get("external-id", []) if ext_ids else []
153
+ doi = ""
154
+ for eid in ext_id_list:
155
+ if eid.get("external-id-type") == "doi":
156
+ doi = eid.get("external-id-value", "")
157
+ break
158
+
159
+ lines = [f"TY - {ris_type}"]
160
+ if title:
161
+ lines.append(f"TI - {title}")
162
+ if journal_name:
163
+ lines.append(f"JO - {journal_name}")
164
+ if year:
165
+ lines.append(f"PY - {year}")
166
+ if doi:
167
+ lines.append(f"DO - {doi}")
168
+ lines.append("ER - ")
169
+ return "\n".join(lines)
170
+
171
+
172
+ def _work_to_bibtex(work_summary: dict) -> str:
173
+ """Convert an ORCID work summary to BibTeX format."""
174
+ title_obj = work_summary.get("title", {})
175
+ title = ""
176
+ if title_obj:
177
+ title_val = title_obj.get("title", {})
178
+ title = title_val.get("value", "") if isinstance(title_val, dict) else str(title_val)
179
+
180
+ work_type = work_summary.get("type", "")
181
+ bib_type = "article" if "journal" in work_type.lower() else "misc"
182
+
183
+ pub_date = work_summary.get("publication-date") or {}
184
+ year = ""
185
+ if pub_date and pub_date.get("year"):
186
+ year = pub_date["year"].get("value", "")
187
+
188
+ journal = work_summary.get("journal-title")
189
+ journal_name = ""
190
+ if journal:
191
+ journal_name = journal.get("value", "") if isinstance(journal, dict) else str(journal)
192
+
193
+ ext_ids = work_summary.get("external-ids", {})
194
+ ext_id_list = ext_ids.get("external-id", []) if ext_ids else []
195
+ doi = ""
196
+ for eid in ext_id_list:
197
+ if eid.get("external-id-type") == "doi":
198
+ doi = eid.get("external-id-value", "")
199
+ break
200
+
201
+ key = f"orcid{year or 'nd'}"
202
+ lines = [f"@{bib_type}{{{key},"]
203
+ if title:
204
+ lines.append(f" title = {{{title}}},")
205
+ if journal_name:
206
+ lines.append(f" journal = {{{journal_name}}},")
207
+ if year:
208
+ lines.append(f" year = {{{year}}},")
209
+ if doi:
210
+ lines.append(f" doi = {{{doi}}},")
211
+ lines.append("}")
212
+ return "\n".join(lines)
213
+
214
+
215
+ # ── Store last works results for export ──────────────────────────────────
216
+
217
+ _last_works: list = []
218
+
219
+
220
+ # ── Tools ─────────────────────────────────────────────────────────────────
221
+
222
+ @mcp.tool()
223
+ async def orcid_search(
224
+ query: str,
225
+ search_type: str = "name",
226
+ count: int = 25,
227
+ ) -> str:
228
+ """
229
+ Search the ORCID registry for researchers.
230
+
231
+ Args:
232
+ query: Search terms (name, keyword, affiliation, DOI, ORCID iD)
233
+ search_type: One of: name, affiliation, keyword, doi, advanced
234
+ - name: searches by researcher name
235
+ - affiliation: searches by institution/organization name
236
+ - keyword: searches researcher keywords/biography
237
+ - doi: finds the ORCID record linked to a specific DOI
238
+ - advanced: pass raw Solr query (e.g. "family-name:Einstein AND keyword:Relativity")
239
+ count: Number of results (max 100, default 25)
240
+ """
241
+ query_map = {
242
+ "name": lambda q: f"given-and-family-names:{q}",
243
+ "affiliation": lambda q: f"affiliation-org-name:{q}",
244
+ "keyword": lambda q: f"keyword:{q}",
245
+ "doi": lambda q: f'doi-self:"{q}"',
246
+ "advanced": lambda q: q,
247
+ }
248
+ builder = query_map.get(search_type, query_map["name"])
249
+ solr_query = builder(query)
250
+
251
+ try:
252
+ data = await _get(
253
+ f"{BASE_URL}/expanded-search/",
254
+ {"q": solr_query, "rows": min(count, 100), "start": 0},
255
+ )
256
+
257
+ results = data.get("expanded-result", [])
258
+ total = data.get("num-found", 0)
259
+
260
+ if not results:
261
+ return f"No results found for: {query}"
262
+
263
+ header = f"ORCID Search: {total} total results, showing {len(results)}\n"
264
+ header += f"Query: {solr_query}\n"
265
+ header += "=" * 60 + "\n\n"
266
+ formatted = "\n\n".join(
267
+ _format_search_result(i, r) for i, r in enumerate(results, 1)
268
+ )
269
+ return header + formatted
270
+ except httpx.HTTPStatusError as e:
271
+ return f"ORCID API error: {e.response.status_code} — {e.response.text}"
272
+ except Exception as e:
273
+ return f"Error: {str(e)}"
274
+
275
+
276
+ @mcp.tool()
277
+ async def orcid_read_record(orcid_id: str) -> str:
278
+ """
279
+ Read a researcher's full ORCID profile.
280
+
281
+ Args:
282
+ orcid_id: The ORCID iD (e.g. "0000-0002-1825-0097")
283
+ """
284
+ try:
285
+ data = await _get(f"{BASE_URL}/{orcid_id}/record")
286
+
287
+ # Person details
288
+ person = data.get("person", {})
289
+ name_obj = person.get("name", {})
290
+ given = ""
291
+ family = ""
292
+ credit = ""
293
+ if name_obj:
294
+ gn = name_obj.get("given-names")
295
+ given = gn.get("value", "") if isinstance(gn, dict) else (gn or "")
296
+ fn = name_obj.get("family-name")
297
+ family = fn.get("value", "") if isinstance(fn, dict) else (fn or "")
298
+ cn = name_obj.get("credit-name")
299
+ credit = cn.get("value", "") if isinstance(cn, dict) else (cn or "")
300
+
301
+ bio_obj = person.get("biography")
302
+ bio = ""
303
+ if bio_obj:
304
+ bio = bio_obj.get("content", "") if isinstance(bio_obj, dict) else str(bio_obj)
305
+
306
+ # Keywords
307
+ kw_obj = person.get("keywords", {})
308
+ kw_list = kw_obj.get("keyword", []) if kw_obj else []
309
+ keywords = [k.get("content", "") for k in kw_list if k.get("content")]
310
+
311
+ # Researcher URLs
312
+ urls_obj = person.get("researcher-urls", {})
313
+ url_list = urls_obj.get("researcher-url", []) if urls_obj else []
314
+ urls = [(u.get("url-name", ""), u.get("url", {}).get("value", "")) for u in url_list]
315
+
316
+ # Emails
317
+ emails_obj = person.get("emails", {})
318
+ email_list = emails_obj.get("email", []) if emails_obj else []
319
+ emails = [e.get("email", "") for e in email_list if e.get("email")]
320
+
321
+ # Activities summary
322
+ activities = data.get("activities-summary", {})
323
+
324
+ # Employments
325
+ emp_obj = activities.get("employments", {})
326
+ emp_groups = emp_obj.get("affiliation-group", []) if emp_obj else []
327
+ employments = []
328
+ for group in emp_groups:
329
+ summaries = group.get("summaries", [])
330
+ for s in summaries:
331
+ emp = s.get("employment-summary", {})
332
+ org = emp.get("organization", {})
333
+ org_name = org.get("name", "")
334
+ role = emp.get("role-title", "")
335
+ dept = emp.get("department-name", "")
336
+ start = emp.get("start-date")
337
+ end = emp.get("end-date")
338
+ start_yr = start.get("year", {}).get("value", "") if start else ""
339
+ end_yr = end.get("year", {}).get("value", "present") if end else "present"
340
+ entry = org_name
341
+ if role:
342
+ entry = f"{role}, {entry}"
343
+ if dept:
344
+ entry += f" ({dept})"
345
+ if start_yr:
346
+ entry += f" [{start_yr}–{end_yr}]"
347
+ employments.append(entry)
348
+
349
+ # Educations
350
+ edu_obj = activities.get("educations", {})
351
+ edu_groups = edu_obj.get("affiliation-group", []) if edu_obj else []
352
+ educations = []
353
+ for group in edu_groups:
354
+ summaries = group.get("summaries", [])
355
+ for s in summaries:
356
+ edu = s.get("education-summary", {})
357
+ org = edu.get("organization", {})
358
+ org_name = org.get("name", "")
359
+ role = edu.get("role-title", "")
360
+ dept = edu.get("department-name", "")
361
+ start = edu.get("start-date")
362
+ end = edu.get("end-date")
363
+ start_yr = start.get("year", {}).get("value", "") if start else ""
364
+ end_yr = end.get("year", {}).get("value", "") if end else ""
365
+ entry = org_name
366
+ if role:
367
+ entry = f"{role}, {entry}"
368
+ if dept:
369
+ entry += f" ({dept})"
370
+ if start_yr:
371
+ entry += f" [{start_yr}–{end_yr}]" if end_yr else f" [{start_yr}–]"
372
+ educations.append(entry)
373
+
374
+ # Works count
375
+ works_obj = activities.get("works", {})
376
+ work_groups = works_obj.get("group", []) if works_obj else []
377
+ works_count = len(work_groups)
378
+
379
+ # Fundings count
380
+ fund_obj = activities.get("fundings", {})
381
+ fund_groups = fund_obj.get("group", []) if fund_obj else []
382
+ fundings_count = len(fund_groups)
383
+
384
+ # Build output
385
+ display_name = credit if credit else f"{given} {family}".strip()
386
+ output = f"Name: {display_name}\n"
387
+ output += f"ORCID: https://orcid.org/{orcid_id}\n"
388
+ if emails:
389
+ output += f"Email: {'; '.join(emails)}\n"
390
+ if bio:
391
+ output += f"\nBiography:\n{bio}\n"
392
+ if keywords:
393
+ output += f"\nKeywords: {'; '.join(keywords)}\n"
394
+ if urls:
395
+ output += "\nLinks:\n"
396
+ for name, url in urls:
397
+ output += f" - {name}: {url}\n" if name else f" - {url}\n"
398
+ if employments:
399
+ output += f"\nEmployment ({len(employments)}):\n"
400
+ for emp in employments:
401
+ output += f" - {emp}\n"
402
+ if educations:
403
+ output += f"\nEducation ({len(educations)}):\n"
404
+ for edu in educations:
405
+ output += f" - {edu}\n"
406
+ output += f"\nWorks: {works_count} items"
407
+ if works_count > 0:
408
+ output += " (use orcid_read_works to see them)"
409
+ output += f"\nFunding: {fundings_count} items"
410
+
411
+ return output
412
+ except httpx.HTTPStatusError as e:
413
+ return f"ORCID API error: {e.response.status_code} — {e.response.text}"
414
+ except Exception as e:
415
+ return f"Error: {str(e)}"
416
+
417
+
418
+ @mcp.tool()
419
+ async def orcid_read_works(
420
+ orcid_id: str,
421
+ count: int = 25,
422
+ ) -> str:
423
+ """
424
+ Get publications from an ORCID researcher profile.
425
+
426
+ Args:
427
+ orcid_id: The ORCID iD (e.g. "0000-0002-1825-0097")
428
+ count: Maximum number of works to return (default 25)
429
+ """
430
+ global _last_works
431
+ try:
432
+ data = await _get(f"{BASE_URL}/{orcid_id}/works")
433
+ groups = data.get("group", [])
434
+
435
+ if not groups:
436
+ _last_works = []
437
+ return f"No works found for ORCID {orcid_id}"
438
+
439
+ # Each group has work-summary entries; take the first summary per group
440
+ works = []
441
+ for group in groups[:count]:
442
+ summaries = group.get("work-summary", [])
443
+ if summaries:
444
+ works.append(summaries[0])
445
+
446
+ _last_works = works
447
+
448
+ header = f"Works for ORCID {orcid_id}: {len(groups)} total, showing {len(works)}\n"
449
+ header += "=" * 60 + "\n\n"
450
+ formatted = "\n\n".join(
451
+ _format_work(i, w) for i, w in enumerate(works, 1)
452
+ )
453
+ return header + formatted
454
+ except httpx.HTTPStatusError as e:
455
+ return f"ORCID API error: {e.response.status_code} — {e.response.text}"
456
+ except Exception as e:
457
+ return f"Error: {str(e)}"
458
+
459
+
460
+ @mcp.tool()
461
+ async def orcid_export_ris() -> str:
462
+ """
463
+ Export the most recent orcid_read_works results as RIS format.
464
+ Save output as a .ris file and import into Zotero: File -> Import.
465
+ """
466
+ if not _last_works:
467
+ return "No works to export. Run orcid_read_works first."
468
+ records = [_work_to_ris(w) for w in _last_works]
469
+ count = len(records)
470
+ return f"RIS Export ({count} records) — Save as .ris and import into Zotero:\n\n" + "\n\n".join(records)
471
+
472
+
473
+ @mcp.tool()
474
+ async def orcid_export_bibtex() -> str:
475
+ """
476
+ Export the most recent orcid_read_works results as BibTeX format.
477
+ """
478
+ if not _last_works:
479
+ return "No works to export. Run orcid_read_works first."
480
+ records = [_work_to_bibtex(w) for w in _last_works]
481
+ count = len(records)
482
+ return f"BibTeX Export ({count} records):\n\n" + "\n\n".join(records)
483
+
484
+
485
+ # ── Run ────────────────────────────────────────────────────────────────────
486
+
487
+ if __name__ == "__main__":
488
+ mcp.run()