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.
- orcid_mcp_server-1.0.0/.env.example +2 -0
- orcid_mcp_server-1.0.0/.gitignore +9 -0
- orcid_mcp_server-1.0.0/LICENSE +21 -0
- orcid_mcp_server-1.0.0/PKG-INFO +100 -0
- orcid_mcp_server-1.0.0/README.md +82 -0
- orcid_mcp_server-1.0.0/pyproject.toml +29 -0
- orcid_mcp_server-1.0.0/requirements.txt +2 -0
- orcid_mcp_server-1.0.0/server.py +488 -0
- orcid_mcp_server-1.0.0/src/orcid_mcp_server/__init__.py +4 -0
- orcid_mcp_server-1.0.0/src/orcid_mcp_server/server.py +488 -0
|
@@ -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,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,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()
|