flonat-research 0.1.0

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.
Files changed (285) hide show
  1. package/.claude/agents/domain-reviewer.md +336 -0
  2. package/.claude/agents/fixer.md +226 -0
  3. package/.claude/agents/paper-critic.md +370 -0
  4. package/.claude/agents/peer-reviewer.md +289 -0
  5. package/.claude/agents/proposal-reviewer.md +215 -0
  6. package/.claude/agents/referee2-reviewer.md +367 -0
  7. package/.claude/agents/references/journal-referee-profiles.md +354 -0
  8. package/.claude/agents/references/paper-critic/council-personas.md +77 -0
  9. package/.claude/agents/references/paper-critic/council-prompts.md +198 -0
  10. package/.claude/agents/references/peer-reviewer/report-template.md +199 -0
  11. package/.claude/agents/references/peer-reviewer/sa-prompts.md +260 -0
  12. package/.claude/agents/references/peer-reviewer/security-scan.md +188 -0
  13. package/.claude/agents/references/proposal-reviewer/report-template.md +144 -0
  14. package/.claude/agents/references/proposal-reviewer/sa-prompts.md +149 -0
  15. package/.claude/agents/references/referee-config.md +114 -0
  16. package/.claude/agents/references/referee2-reviewer/audit-checklists.md +287 -0
  17. package/.claude/agents/references/referee2-reviewer/report-template.md +334 -0
  18. package/.claude/rules/design-before-results.md +52 -0
  19. package/.claude/rules/ignore-agents-md.md +17 -0
  20. package/.claude/rules/ignore-gemini-md.md +17 -0
  21. package/.claude/rules/lean-claude-md.md +45 -0
  22. package/.claude/rules/learn-tags.md +99 -0
  23. package/.claude/rules/overleaf-separation.md +67 -0
  24. package/.claude/rules/plan-first.md +175 -0
  25. package/.claude/rules/read-docs-first.md +50 -0
  26. package/.claude/rules/scope-discipline.md +28 -0
  27. package/.claude/settings.json +125 -0
  28. package/.context/current-focus.md +33 -0
  29. package/.context/preferences/priorities.md +36 -0
  30. package/.context/preferences/task-naming.md +28 -0
  31. package/.context/profile.md +29 -0
  32. package/.context/projects/_index.md +41 -0
  33. package/.context/projects/papers/nudge-exp.md +22 -0
  34. package/.context/projects/papers/uncertainty.md +31 -0
  35. package/.context/resources/claude-scientific-writer-review.md +48 -0
  36. package/.context/resources/cunningham-multi-analyst-agents.md +104 -0
  37. package/.context/resources/cunningham-multilang-code-audit.md +62 -0
  38. package/.context/resources/google-ai-co-scientist-review.md +72 -0
  39. package/.context/resources/karpathy-llm-council-review.md +58 -0
  40. package/.context/resources/multi-coder-reliability-protocol.md +175 -0
  41. package/.context/resources/pedro-santanna-takeaways.md +96 -0
  42. package/.context/resources/venue-rankings/abs_ajg_2024.csv +1823 -0
  43. package/.context/resources/venue-rankings/abs_ajg_2024_econ.csv +356 -0
  44. package/.context/resources/venue-rankings/cabs_4_4star_theory.csv +40 -0
  45. package/.context/resources/venue-rankings/core_2026.csv +801 -0
  46. package/.context/resources/venue-rankings.md +147 -0
  47. package/.context/workflows/README.md +69 -0
  48. package/.context/workflows/daily-review.md +91 -0
  49. package/.context/workflows/meeting-actions.md +108 -0
  50. package/.context/workflows/replication-protocol.md +155 -0
  51. package/.context/workflows/weekly-review.md +113 -0
  52. package/.mcp-server-biblio/formatters.py +158 -0
  53. package/.mcp-server-biblio/pyproject.toml +11 -0
  54. package/.mcp-server-biblio/server.py +678 -0
  55. package/.mcp-server-biblio/sources/__init__.py +14 -0
  56. package/.mcp-server-biblio/sources/base.py +73 -0
  57. package/.mcp-server-biblio/sources/formatters.py +83 -0
  58. package/.mcp-server-biblio/sources/models.py +22 -0
  59. package/.mcp-server-biblio/sources/multi_source.py +243 -0
  60. package/.mcp-server-biblio/sources/openalex_source.py +183 -0
  61. package/.mcp-server-biblio/sources/scopus_source.py +309 -0
  62. package/.mcp-server-biblio/sources/wos_source.py +508 -0
  63. package/.mcp-server-biblio/uv.lock +896 -0
  64. package/.scripts/README.md +161 -0
  65. package/.scripts/ai_pattern_density.py +446 -0
  66. package/.scripts/conf +445 -0
  67. package/.scripts/config.py +122 -0
  68. package/.scripts/count_inventory.py +275 -0
  69. package/.scripts/daily_digest.py +288 -0
  70. package/.scripts/done +177 -0
  71. package/.scripts/extract_meeting_actions.py +223 -0
  72. package/.scripts/focus +176 -0
  73. package/.scripts/generate-codex-agents-md.py +217 -0
  74. package/.scripts/inbox +194 -0
  75. package/.scripts/notion_helpers.py +325 -0
  76. package/.scripts/openalex/query_helpers.py +306 -0
  77. package/.scripts/papers +227 -0
  78. package/.scripts/query +223 -0
  79. package/.scripts/session-history.py +201 -0
  80. package/.scripts/skill-health.py +516 -0
  81. package/.scripts/skill-log-miner.py +273 -0
  82. package/.scripts/sync-to-codex.sh +252 -0
  83. package/.scripts/task +213 -0
  84. package/.scripts/tasks +190 -0
  85. package/.scripts/week +206 -0
  86. package/CLAUDE.md +197 -0
  87. package/LICENSE +21 -0
  88. package/MEMORY.md +38 -0
  89. package/README.md +269 -0
  90. package/docs/agents.md +44 -0
  91. package/docs/bibliography-setup.md +55 -0
  92. package/docs/council-mode.md +36 -0
  93. package/docs/getting-started.md +245 -0
  94. package/docs/hooks.md +38 -0
  95. package/docs/mcp-servers.md +82 -0
  96. package/docs/notion-setup.md +109 -0
  97. package/docs/rules.md +33 -0
  98. package/docs/scripts.md +303 -0
  99. package/docs/setup-overview/setup-overview.pdf +0 -0
  100. package/docs/skills.md +70 -0
  101. package/docs/system.md +159 -0
  102. package/hooks/block-destructive-git.sh +66 -0
  103. package/hooks/context-monitor.py +114 -0
  104. package/hooks/postcompact-restore.py +157 -0
  105. package/hooks/precompact-autosave.py +181 -0
  106. package/hooks/promise-checker.sh +124 -0
  107. package/hooks/protect-source-files.sh +81 -0
  108. package/hooks/resume-context-loader.sh +53 -0
  109. package/hooks/startup-context-loader.sh +102 -0
  110. package/package.json +51 -0
  111. package/packages/cli-council/.github/workflows/claude-code-review.yml +44 -0
  112. package/packages/cli-council/.github/workflows/claude.yml +50 -0
  113. package/packages/cli-council/README.md +100 -0
  114. package/packages/cli-council/pyproject.toml +43 -0
  115. package/packages/cli-council/src/cli_council/__init__.py +19 -0
  116. package/packages/cli-council/src/cli_council/__main__.py +185 -0
  117. package/packages/cli-council/src/cli_council/backends/__init__.py +8 -0
  118. package/packages/cli-council/src/cli_council/backends/base.py +81 -0
  119. package/packages/cli-council/src/cli_council/backends/claude.py +25 -0
  120. package/packages/cli-council/src/cli_council/backends/codex.py +27 -0
  121. package/packages/cli-council/src/cli_council/backends/gemini.py +26 -0
  122. package/packages/cli-council/src/cli_council/checkpoint.py +212 -0
  123. package/packages/cli-council/src/cli_council/config.py +51 -0
  124. package/packages/cli-council/src/cli_council/council.py +391 -0
  125. package/packages/cli-council/src/cli_council/models.py +46 -0
  126. package/packages/llm-council/.github/workflows/claude-code-review.yml +44 -0
  127. package/packages/llm-council/.github/workflows/claude.yml +50 -0
  128. package/packages/llm-council/README.md +453 -0
  129. package/packages/llm-council/pyproject.toml +42 -0
  130. package/packages/llm-council/src/llm_council/__init__.py +23 -0
  131. package/packages/llm-council/src/llm_council/__main__.py +259 -0
  132. package/packages/llm-council/src/llm_council/checkpoint.py +193 -0
  133. package/packages/llm-council/src/llm_council/client.py +253 -0
  134. package/packages/llm-council/src/llm_council/config.py +232 -0
  135. package/packages/llm-council/src/llm_council/council.py +482 -0
  136. package/packages/llm-council/src/llm_council/models.py +46 -0
  137. package/packages/mcp-bibliography/MEMORY.md +31 -0
  138. package/packages/mcp-bibliography/_app.py +226 -0
  139. package/packages/mcp-bibliography/formatters.py +158 -0
  140. package/packages/mcp-bibliography/log/2026-03-13-2100.md +35 -0
  141. package/packages/mcp-bibliography/pyproject.toml +15 -0
  142. package/packages/mcp-bibliography/run.sh +20 -0
  143. package/packages/mcp-bibliography/scholarly_formatters.py +83 -0
  144. package/packages/mcp-bibliography/server.py +1857 -0
  145. package/packages/mcp-bibliography/tools/__init__.py +28 -0
  146. package/packages/mcp-bibliography/tools/_registry.py +19 -0
  147. package/packages/mcp-bibliography/tools/altmetric.py +107 -0
  148. package/packages/mcp-bibliography/tools/core.py +92 -0
  149. package/packages/mcp-bibliography/tools/dblp.py +52 -0
  150. package/packages/mcp-bibliography/tools/openalex.py +296 -0
  151. package/packages/mcp-bibliography/tools/opencitations.py +102 -0
  152. package/packages/mcp-bibliography/tools/openreview.py +179 -0
  153. package/packages/mcp-bibliography/tools/orcid.py +131 -0
  154. package/packages/mcp-bibliography/tools/scholarly.py +575 -0
  155. package/packages/mcp-bibliography/tools/unpaywall.py +63 -0
  156. package/packages/mcp-bibliography/tools/zenodo.py +123 -0
  157. package/packages/mcp-bibliography/uv.lock +711 -0
  158. package/scripts/setup.sh +143 -0
  159. package/skills/beamer-deck/SKILL.md +199 -0
  160. package/skills/beamer-deck/references/quality-rubric.md +54 -0
  161. package/skills/beamer-deck/references/review-prompts.md +106 -0
  162. package/skills/bib-validate/SKILL.md +261 -0
  163. package/skills/bib-validate/references/council-mode.md +34 -0
  164. package/skills/bib-validate/references/deep-verify.md +79 -0
  165. package/skills/bib-validate/references/fix-mode.md +36 -0
  166. package/skills/bib-validate/references/openalex-verification.md +45 -0
  167. package/skills/bib-validate/references/preprint-check.md +31 -0
  168. package/skills/bib-validate/references/ref-manager-crossref.md +41 -0
  169. package/skills/bib-validate/references/report-template.md +82 -0
  170. package/skills/code-archaeology/SKILL.md +141 -0
  171. package/skills/code-review/SKILL.md +265 -0
  172. package/skills/code-review/references/quality-rubric.md +67 -0
  173. package/skills/consolidate-memory/SKILL.md +208 -0
  174. package/skills/context-status/SKILL.md +126 -0
  175. package/skills/creation-guard/SKILL.md +230 -0
  176. package/skills/devils-advocate/SKILL.md +130 -0
  177. package/skills/devils-advocate/references/competing-hypotheses.md +83 -0
  178. package/skills/init-project/SKILL.md +115 -0
  179. package/skills/init-project-course/references/memory-and-settings.md +92 -0
  180. package/skills/init-project-course/references/organise-templates.md +94 -0
  181. package/skills/init-project-course/skill.md +147 -0
  182. package/skills/init-project-light/skill.md +139 -0
  183. package/skills/init-project-research/SKILL.md +368 -0
  184. package/skills/init-project-research/references/atlas-pipeline-sync.md +70 -0
  185. package/skills/init-project-research/references/atlas-schema.md +81 -0
  186. package/skills/init-project-research/references/confirmation-report.md +39 -0
  187. package/skills/init-project-research/references/domain-profile-template.md +104 -0
  188. package/skills/init-project-research/references/interview-round3.md +34 -0
  189. package/skills/init-project-research/references/literature-discovery.md +43 -0
  190. package/skills/init-project-research/references/scaffold-details.md +197 -0
  191. package/skills/init-project-research/templates/field-calibration.md +60 -0
  192. package/skills/init-project-research/templates/pipeline-manifest.md +63 -0
  193. package/skills/init-project-research/templates/run-all.sh +116 -0
  194. package/skills/init-project-research/templates/seed-files.md +337 -0
  195. package/skills/insights-deck/SKILL.md +151 -0
  196. package/skills/interview-me/SKILL.md +157 -0
  197. package/skills/latex/SKILL.md +141 -0
  198. package/skills/latex/references/latex-configs.md +183 -0
  199. package/skills/latex-autofix/SKILL.md +230 -0
  200. package/skills/latex-autofix/references/known-errors.md +183 -0
  201. package/skills/latex-autofix/references/quality-rubric.md +50 -0
  202. package/skills/latex-health-check/SKILL.md +161 -0
  203. package/skills/learn/SKILL.md +220 -0
  204. package/skills/learn/scripts/validate_skill.py +265 -0
  205. package/skills/lessons-learned/SKILL.md +201 -0
  206. package/skills/literature/SKILL.md +335 -0
  207. package/skills/literature/references/agent-templates.md +393 -0
  208. package/skills/literature/references/bibliometric-apis.md +44 -0
  209. package/skills/literature/references/cli-council-search.md +79 -0
  210. package/skills/literature/references/openalex-api-guide.md +371 -0
  211. package/skills/literature/references/openalex-common-queries.md +381 -0
  212. package/skills/literature/references/openalex-workflows.md +248 -0
  213. package/skills/literature/references/reference-manager-sync.md +36 -0
  214. package/skills/literature/references/scopus-api-guide.md +208 -0
  215. package/skills/literature/references/wos-api-guide.md +308 -0
  216. package/skills/multi-perspective/SKILL.md +311 -0
  217. package/skills/multi-perspective/references/computational-many-analysts.md +77 -0
  218. package/skills/pipeline-manifest/SKILL.md +226 -0
  219. package/skills/pre-submission-report/SKILL.md +153 -0
  220. package/skills/process-reviews/SKILL.md +244 -0
  221. package/skills/process-reviews/references/rr-routing.md +101 -0
  222. package/skills/project-deck/SKILL.md +87 -0
  223. package/skills/project-safety/SKILL.md +135 -0
  224. package/skills/proofread/SKILL.md +254 -0
  225. package/skills/proofread/references/quality-rubric.md +104 -0
  226. package/skills/python-env/SKILL.md +57 -0
  227. package/skills/quarto-deck/SKILL.md +226 -0
  228. package/skills/quarto-deck/references/markdown-format.md +143 -0
  229. package/skills/quarto-deck/references/quality-rubric.md +54 -0
  230. package/skills/save-context/SKILL.md +174 -0
  231. package/skills/session-log/SKILL.md +98 -0
  232. package/skills/shared/concept-validation-gate.md +161 -0
  233. package/skills/shared/council-protocol.md +265 -0
  234. package/skills/shared/distribution-diagnostics.md +164 -0
  235. package/skills/shared/engagement-stratified-sampling.md +218 -0
  236. package/skills/shared/escalation-protocol.md +74 -0
  237. package/skills/shared/external-audit-protocol.md +205 -0
  238. package/skills/shared/intercoder-reliability.md +256 -0
  239. package/skills/shared/mcp-degradation.md +81 -0
  240. package/skills/shared/method-probing-questions.md +163 -0
  241. package/skills/shared/multi-language-conventions.md +143 -0
  242. package/skills/shared/paid-api-safety.md +174 -0
  243. package/skills/shared/palettes.md +90 -0
  244. package/skills/shared/progressive-disclosure.md +92 -0
  245. package/skills/shared/project-documentation-content.md +443 -0
  246. package/skills/shared/project-documentation-format.md +281 -0
  247. package/skills/shared/project-documentation.md +100 -0
  248. package/skills/shared/publication-output.md +138 -0
  249. package/skills/shared/quality-scoring.md +70 -0
  250. package/skills/shared/reference-resolution.md +77 -0
  251. package/skills/shared/research-quality-rubric.md +165 -0
  252. package/skills/shared/rhetoric-principles.md +54 -0
  253. package/skills/shared/skill-design-patterns.md +272 -0
  254. package/skills/shared/skill-index.md +240 -0
  255. package/skills/shared/system-documentation.md +334 -0
  256. package/skills/shared/tikz-rules.md +402 -0
  257. package/skills/shared/validation-tiers.md +121 -0
  258. package/skills/shared/venue-guides/README.md +46 -0
  259. package/skills/shared/venue-guides/cell_press_style.md +483 -0
  260. package/skills/shared/venue-guides/conferences_formatting.md +564 -0
  261. package/skills/shared/venue-guides/cs_conference_style.md +463 -0
  262. package/skills/shared/venue-guides/examples/cell_summary_example.md +247 -0
  263. package/skills/shared/venue-guides/examples/medical_structured_abstract.md +313 -0
  264. package/skills/shared/venue-guides/examples/nature_abstract_examples.md +213 -0
  265. package/skills/shared/venue-guides/examples/neurips_introduction_example.md +245 -0
  266. package/skills/shared/venue-guides/journals_formatting.md +486 -0
  267. package/skills/shared/venue-guides/medical_journal_styles.md +535 -0
  268. package/skills/shared/venue-guides/ml_conference_style.md +556 -0
  269. package/skills/shared/venue-guides/nature_science_style.md +405 -0
  270. package/skills/shared/venue-guides/reviewer_expectations.md +417 -0
  271. package/skills/shared/venue-guides/venue_writing_styles.md +321 -0
  272. package/skills/split-pdf/SKILL.md +172 -0
  273. package/skills/split-pdf/methodology.md +48 -0
  274. package/skills/sync-notion/SKILL.md +93 -0
  275. package/skills/system-audit/SKILL.md +157 -0
  276. package/skills/system-audit/references/sub-agent-prompts.md +294 -0
  277. package/skills/task-management/SKILL.md +131 -0
  278. package/skills/update-focus/SKILL.md +204 -0
  279. package/skills/update-project-doc/SKILL.md +194 -0
  280. package/skills/validate-bib/SKILL.md +242 -0
  281. package/skills/validate-bib/references/council-mode.md +34 -0
  282. package/skills/validate-bib/references/deep-verify.md +71 -0
  283. package/skills/validate-bib/references/openalex-verification.md +45 -0
  284. package/skills/validate-bib/references/preprint-check.md +31 -0
  285. package/skills/validate-bib/references/report-template.md +62 -0
@@ -0,0 +1,1857 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Biblio MCP Server
4
+
5
+ Multi-source scholarly search: OpenAlex + Semantic Scholar + Crossref (always) + Scopus + Web of Science (when API keys provided).
6
+ Exposes source-specific openalex_*/crossref_* tools and cross-source scholarly_* tools.
7
+ Imports the shared clients from biblio-sources package — single source of truth.
8
+ """
9
+
10
+ import asyncio
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from mcp.server import Server
16
+ from mcp.server.stdio import stdio_server
17
+ from mcp.types import Tool, TextContent
18
+
19
+ from biblio_sources import (
20
+ AltmetricClient,
21
+ CoreSource,
22
+ CrossrefSource,
23
+ DblpSource,
24
+ OpenCitationsClient,
25
+ OpenReviewClient,
26
+ UnpaywallClient,
27
+ ZenodoClient,
28
+ OrcidClient,
29
+ OpenAlexClient,
30
+ OpenAlexSource,
31
+ MultiSource,
32
+ SemanticScholarSource,
33
+ ScopusSource,
34
+ WosSource,
35
+ )
36
+
37
+ # OpenAlex-specific helpers (raw dict API)
38
+ SCRIPTS_DIR = str(Path(__file__).parent.parent.parent / ".scripts" / "openalex")
39
+ sys.path.insert(0, SCRIPTS_DIR)
40
+
41
+ from query_helpers import ( # noqa: E402
42
+ find_author_works,
43
+ analyze_research_output,
44
+ get_publication_trends,
45
+ )
46
+
47
+ from formatters import ( # noqa: E402
48
+ format_works_table,
49
+ format_author_profile,
50
+ format_trends,
51
+ format_work_detail,
52
+ )
53
+
54
+ from scholarly_formatters import ( # noqa: E402
55
+ format_papers_table,
56
+ format_verification_table,
57
+ format_source_status,
58
+ )
59
+
60
+
61
+ def log(msg):
62
+ print(f"[bibliography-mcp] {msg}", file=sys.stderr, flush=True)
63
+
64
+
65
+ # Shared client instance (polite pool)
66
+ client = OpenAlexClient(email="user@example.com")
67
+
68
+ # ---------- Multi-source initialization ----------
69
+
70
+ _all_sources = []
71
+ _source_info = []
72
+
73
+ # OpenAlex — always available
74
+ _openalex_source = OpenAlexSource(client)
75
+ _all_sources.append(_openalex_source)
76
+ _source_info.append({"name": "OpenAlex", "key": "openalex", "active": True})
77
+ log("OpenAlex source: active")
78
+
79
+ # Semantic Scholar — always available, optional API key for higher rate limits
80
+ _s2_key = os.environ.get("S2_API_KEY")
81
+ _s2_source = SemanticScholarSource(api_key=_s2_key)
82
+ _all_sources.append(_s2_source)
83
+ _source_info.append({"name": "Semantic Scholar", "key": "s2", "active": True})
84
+ log(f"Semantic Scholar source: active{' (API key)' if _s2_key else ' (no key, 1 req/sec)'}")
85
+
86
+ # Crossref — always available (no API key, polite pool via mailto)
87
+ _crossref_source = CrossrefSource(mailto="user@example.com")
88
+ _all_sources.append(_crossref_source)
89
+ _source_info.append({"name": "Crossref", "key": "crossref", "active": True})
90
+ log("Crossref source: active (authoritative DOI registry)")
91
+
92
+ # Scopus — optional, requires SCOPUS_API_KEY
93
+ _scopus_key = os.environ.get("SCOPUS_API_KEY")
94
+ if _scopus_key:
95
+ _scopus_inst_token = os.environ.get("SCOPUS_INST_TOKEN", "")
96
+ _scopus_source = ScopusSource(_scopus_key, inst_token=_scopus_inst_token)
97
+ _all_sources.append(_scopus_source)
98
+ _source_info.append({"name": "Scopus", "key": "scopus", "active": True})
99
+ log(f"Scopus source: active{' (InstToken)' if _scopus_inst_token else ''}")
100
+ else:
101
+ _source_info.append({"name": "Scopus", "key": "scopus", "active": False})
102
+ log("Scopus source: no API key")
103
+
104
+ # Web of Science — optional, requires WOS_API_KEY
105
+ _wos_key = os.environ.get("WOS_API_KEY")
106
+ _wos_tier = os.environ.get("WOS_API_TIER", "starter").lower()
107
+ if _wos_key:
108
+ _wos_source = WosSource(_wos_key, tier=_wos_tier)
109
+ _all_sources.append(_wos_source)
110
+ _source_info.append({"name": f"Web of Science ({_wos_tier})", "key": "wos", "active": True})
111
+ log(f"WoS source: active (tier={_wos_tier})")
112
+ else:
113
+ _source_info.append({"name": "Web of Science", "key": "wos", "active": False})
114
+ log("WoS source: no API key")
115
+
116
+ # Composite source for cross-source queries
117
+ _multi_source = MultiSource(_all_sources) if len(_all_sources) > 1 else _openalex_source
118
+ log(f"Multi-source: {len(_all_sources)} source(s) active")
119
+
120
+ # CORE — open access full text, optional API key
121
+ _core_key = os.environ.get("CORE_API_KEY", "")
122
+ if _core_key:
123
+ _core_source = CoreSource(api_key=_core_key)
124
+ _all_sources.append(_core_source)
125
+ _source_info.append({"name": "CORE", "key": "core", "active": True})
126
+ log("CORE source: active (431M+ records, full-text access)")
127
+ else:
128
+ _core_source = None
129
+ _source_info.append({"name": "CORE", "key": "core", "active": False})
130
+ log("CORE source: no API key (set CORE_API_KEY)")
131
+
132
+ # ORCID — researcher profiles, always available if credentials set
133
+ _orcid_client_id = os.environ.get("ORCID_CLIENT_ID", "")
134
+ _orcid_client_secret = os.environ.get("ORCID_CLIENT_SECRET", "")
135
+ _orcid_client = None
136
+ if _orcid_client_id and _orcid_client_secret:
137
+ _orcid_client = OrcidClient(
138
+ client_id=_orcid_client_id,
139
+ client_secret=_orcid_client_secret,
140
+ )
141
+ log("ORCID client: active")
142
+ else:
143
+ log("ORCID client: no credentials (set ORCID_CLIENT_ID + ORCID_CLIENT_SECRET)")
144
+
145
+ # Altmetric Explorer — research attention metrics, optional
146
+ _altmetric_key = os.environ.get("ALTMETRIC_API_KEY", "")
147
+ _altmetric_secret = os.environ.get("ALTMETRIC_API_PASSWORD", "")
148
+ _altmetric_client = None
149
+ if _altmetric_key and _altmetric_secret:
150
+ _altmetric_client = AltmetricClient(api_key=_altmetric_key, api_secret=_altmetric_secret)
151
+ log("Altmetric Explorer client: active")
152
+ else:
153
+ log("Altmetric Explorer client: no credentials (set ALTMETRIC_API_KEY + ALTMETRIC_API_PASSWORD)")
154
+
155
+ # Zenodo — research data repository, always available (no auth)
156
+ _zenodo_client = ZenodoClient()
157
+ log("Zenodo client: active (no auth required)")
158
+
159
+ # Unpaywall — OA PDF resolver, always available (no auth, just email)
160
+ _unpaywall_client = UnpaywallClient(email="user@example.com")
161
+ log("Unpaywall client: active (no auth required)")
162
+
163
+ # OpenCitations — open citation graph, always available (no auth)
164
+ _opencitations_client = OpenCitationsClient()
165
+ log("OpenCitations client: active (no auth required)")
166
+
167
+ # DBLP — CS publications, always available (no auth)
168
+ _dblp_source = DblpSource()
169
+ log("DBLP source: active (no auth required)")
170
+
171
+ # OpenReview — conference submissions and reviews, always available (no auth)
172
+ _openreview_client = OpenReviewClient()
173
+ log("OpenReview client: active (no auth required)")
174
+
175
+ server = Server("bibliography")
176
+ log("Server initialized")
177
+
178
+
179
+ # ---------- Tool definitions ----------
180
+
181
+ TOOLS = [
182
+ Tool(
183
+ name="openalex_search_works",
184
+ description=(
185
+ "Search OpenAlex for scholarly papers by topic. Supports filters for "
186
+ "year range, minimum citations, open access, and sort order. "
187
+ "Returns a markdown table of results."
188
+ ),
189
+ inputSchema={
190
+ "type": "object",
191
+ "properties": {
192
+ "query": {
193
+ "type": "string",
194
+ "description": "Search query (topic, keywords, title fragment)",
195
+ },
196
+ "year": {
197
+ "type": "string",
198
+ "description": "Year filter: e.g. '2023', '>2020', '2020-2024'",
199
+ },
200
+ "min_citations": {
201
+ "type": "integer",
202
+ "description": "Minimum citation count",
203
+ },
204
+ "open_access": {
205
+ "type": "boolean",
206
+ "description": "Only return open access papers",
207
+ },
208
+ "sort": {
209
+ "type": "string",
210
+ "description": "Sort order: 'cited_by_count:desc' (default), 'publication_date:desc', 'relevance_score:desc'",
211
+ },
212
+ "limit": {
213
+ "type": "integer",
214
+ "description": "Max results (default 25, max 50)",
215
+ },
216
+ },
217
+ "required": ["query"],
218
+ },
219
+ ),
220
+ Tool(
221
+ name="openalex_author_works",
222
+ description=(
223
+ "Find publications by a specific author. Searches by name, "
224
+ "resolves to OpenAlex author ID, returns their works."
225
+ ),
226
+ inputSchema={
227
+ "type": "object",
228
+ "properties": {
229
+ "author_name": {
230
+ "type": "string",
231
+ "description": "Author name to search for",
232
+ },
233
+ "limit": {
234
+ "type": "integer",
235
+ "description": "Max results (default 50, max 100)",
236
+ },
237
+ },
238
+ "required": ["author_name"],
239
+ },
240
+ ),
241
+ Tool(
242
+ name="openalex_author_profile",
243
+ description=(
244
+ "Analyze an author's research output: total works, open access %, "
245
+ "publications by year, and top topics."
246
+ ),
247
+ inputSchema={
248
+ "type": "object",
249
+ "properties": {
250
+ "author_name": {
251
+ "type": "string",
252
+ "description": "Author name to analyze",
253
+ },
254
+ "years": {
255
+ "type": "string",
256
+ "description": "Year filter (default: '>2020')",
257
+ },
258
+ },
259
+ "required": ["author_name"],
260
+ },
261
+ ),
262
+ Tool(
263
+ name="openalex_institution_output",
264
+ description=(
265
+ "Analyze an institution's research output: total works, open access %, "
266
+ "publications by year, and top topics."
267
+ ),
268
+ inputSchema={
269
+ "type": "object",
270
+ "properties": {
271
+ "institution_name": {
272
+ "type": "string",
273
+ "description": "Institution name to analyze",
274
+ },
275
+ "years": {
276
+ "type": "string",
277
+ "description": "Year filter (default: '>2020')",
278
+ },
279
+ },
280
+ "required": ["institution_name"],
281
+ },
282
+ ),
283
+ Tool(
284
+ name="openalex_trends",
285
+ description=(
286
+ "Get publication count trends over time for a search term. "
287
+ "Returns yearly publication counts."
288
+ ),
289
+ inputSchema={
290
+ "type": "object",
291
+ "properties": {
292
+ "query": {
293
+ "type": "string",
294
+ "description": "Search term to track trends for",
295
+ },
296
+ },
297
+ "required": ["query"],
298
+ },
299
+ ),
300
+ Tool(
301
+ name="openalex_lookup_doi",
302
+ description=(
303
+ "Look up a work by DOI. Returns full metadata including title, "
304
+ "authors, abstract, citations, and open access status."
305
+ ),
306
+ inputSchema={
307
+ "type": "object",
308
+ "properties": {
309
+ "doi": {
310
+ "type": "string",
311
+ "description": "DOI (with or without https://doi.org/ prefix)",
312
+ },
313
+ },
314
+ "required": ["doi"],
315
+ },
316
+ ),
317
+ Tool(
318
+ name="openalex_citing_works",
319
+ description=(
320
+ "Find papers that cite a given work (forward citation tracking). "
321
+ "Provide a DOI and get back the citing papers."
322
+ ),
323
+ inputSchema={
324
+ "type": "object",
325
+ "properties": {
326
+ "doi": {
327
+ "type": "string",
328
+ "description": "DOI of the work to find citations for",
329
+ },
330
+ "limit": {
331
+ "type": "integer",
332
+ "description": "Max results (default 25, max 50)",
333
+ },
334
+ },
335
+ "required": ["doi"],
336
+ },
337
+ ),
338
+ Tool(
339
+ name="crossref_lookup_doi",
340
+ description=(
341
+ "Look up a DOI in Crossref, the authoritative DOI registry. Returns "
342
+ "verified metadata: title, authors, journal, date, abstract, citation count. "
343
+ "Use this to verify a DOI exists and get canonical metadata. More authoritative "
344
+ "than OpenAlex for DOI verification."
345
+ ),
346
+ inputSchema={
347
+ "type": "object",
348
+ "properties": {
349
+ "doi": {
350
+ "type": "string",
351
+ "description": "DOI to look up (with or without https://doi.org/ prefix)",
352
+ },
353
+ },
354
+ "required": ["doi"],
355
+ },
356
+ ),
357
+ ]
358
+
359
+
360
+ # ---------- Scholarly tool definitions (cross-source) ----------
361
+
362
+ SCHOLARLY_TOOLS = [
363
+ Tool(
364
+ name="scholarly_search",
365
+ description=(
366
+ "Search for scholarly papers across ALL enabled sources (OpenAlex, Scopus, WoS) "
367
+ "with automatic DOI-based deduplication. Returns merged results with the best "
368
+ "metadata from each source."
369
+ ),
370
+ inputSchema={
371
+ "type": "object",
372
+ "properties": {
373
+ "query": {
374
+ "type": "string",
375
+ "description": "Search query (topic, keywords, title fragment)",
376
+ },
377
+ "year_from": {
378
+ "type": "integer",
379
+ "description": "Start year filter (inclusive)",
380
+ },
381
+ "year_to": {
382
+ "type": "integer",
383
+ "description": "End year filter (inclusive)",
384
+ },
385
+ "sort_by": {
386
+ "type": "string",
387
+ "description": "Sort: 'relevance' (default), 'cited_by_count', 'publication_year'",
388
+ },
389
+ "limit": {
390
+ "type": "integer",
391
+ "description": "Max results (default 25, max 50)",
392
+ },
393
+ },
394
+ "required": ["query"],
395
+ },
396
+ ),
397
+ Tool(
398
+ name="scholarly_verify_dois",
399
+ description=(
400
+ "Batch-verify DOIs across all enabled sources. For each DOI, checks if it exists "
401
+ "in Crossref (authoritative), OpenAlex, Semantic Scholar, Scopus, and/or WoS. "
402
+ "Returns verification status: VERIFIED (2+ sources), SINGLE_SOURCE (1 source), "
403
+ "or NOT_FOUND. The killer tool for /literature Phase 4."
404
+ ),
405
+ inputSchema={
406
+ "type": "object",
407
+ "properties": {
408
+ "dois": {
409
+ "type": "array",
410
+ "items": {"type": "string"},
411
+ "description": "List of DOIs to verify (up to 50). With or without https://doi.org/ prefix.",
412
+ },
413
+ },
414
+ "required": ["dois"],
415
+ },
416
+ ),
417
+ Tool(
418
+ name="scholarly_similar_works",
419
+ description=(
420
+ "Find papers similar to a given text (title or abstract) across all enabled sources. "
421
+ "Results are deduplicated by DOI."
422
+ ),
423
+ inputSchema={
424
+ "type": "object",
425
+ "properties": {
426
+ "text": {
427
+ "type": "string",
428
+ "description": "Text to find similar papers for (title, abstract, or topic description)",
429
+ },
430
+ "limit": {
431
+ "type": "integer",
432
+ "description": "Max results (default 20, max 50)",
433
+ },
434
+ },
435
+ "required": ["text"],
436
+ },
437
+ ),
438
+ Tool(
439
+ name="scholarly_source_status",
440
+ description=(
441
+ "Show which scholarly data sources are configured and active. "
442
+ "Reports OpenAlex (always), Scopus (if SCOPUS_API_KEY set), "
443
+ "WoS (if WOS_API_KEY set)."
444
+ ),
445
+ inputSchema={
446
+ "type": "object",
447
+ "properties": {},
448
+ },
449
+ ),
450
+ Tool(
451
+ name="scholarly_citations",
452
+ description=(
453
+ "Get papers that CITE a given paper (forward citation tracking). "
454
+ "Powered by Semantic Scholar Graph API. Accepts DOI, arXiv ID, or S2 paper ID. "
455
+ "Use for snowball searches, impact analysis, and finding follow-up work."
456
+ ),
457
+ inputSchema={
458
+ "type": "object",
459
+ "properties": {
460
+ "paper_id": {
461
+ "type": "string",
462
+ "description": "Paper identifier: DOI (with DOI: prefix, e.g. 'DOI:10.1234/example'), arXiv ID (ARXIV:2106.15928), or S2 paper ID",
463
+ },
464
+ "limit": {
465
+ "type": "integer",
466
+ "description": "Max results (default 50, max 1000)",
467
+ },
468
+ },
469
+ "required": ["paper_id"],
470
+ },
471
+ ),
472
+ Tool(
473
+ name="scholarly_references",
474
+ description=(
475
+ "Get papers REFERENCED BY a given paper (backward citation / bibliography). "
476
+ "Powered by Semantic Scholar Graph API. Accepts DOI, arXiv ID, or S2 paper ID. "
477
+ "Use for snowball searches, finding foundational works, and tracing intellectual lineage."
478
+ ),
479
+ inputSchema={
480
+ "type": "object",
481
+ "properties": {
482
+ "paper_id": {
483
+ "type": "string",
484
+ "description": "Paper identifier: DOI (with DOI: prefix, e.g. 'DOI:10.1234/example'), arXiv ID (ARXIV:2106.15928), or S2 paper ID",
485
+ },
486
+ "limit": {
487
+ "type": "integer",
488
+ "description": "Max results (default 50, max 1000)",
489
+ },
490
+ },
491
+ "required": ["paper_id"],
492
+ },
493
+ ),
494
+ Tool(
495
+ name="scholarly_paper_detail",
496
+ description=(
497
+ "Get full metadata for a single paper including TLDR (AI summary), "
498
+ "BibTeX citation, open access PDF link, abstract, and citation count. "
499
+ "Powered by Semantic Scholar. Accepts DOI, arXiv ID, or S2 paper ID."
500
+ ),
501
+ inputSchema={
502
+ "type": "object",
503
+ "properties": {
504
+ "paper_id": {
505
+ "type": "string",
506
+ "description": "Paper identifier: DOI (with DOI: prefix), arXiv ID (ARXIV:xxx), or S2 paper ID",
507
+ },
508
+ },
509
+ "required": ["paper_id"],
510
+ },
511
+ ),
512
+ Tool(
513
+ name="scholarly_author_papers",
514
+ description=(
515
+ "Find all papers by an author. First searches for the author by name, "
516
+ "then retrieves their publications. Powered by Semantic Scholar Graph API."
517
+ ),
518
+ inputSchema={
519
+ "type": "object",
520
+ "properties": {
521
+ "author_name": {
522
+ "type": "string",
523
+ "description": "Author name to search for",
524
+ },
525
+ "limit": {
526
+ "type": "integer",
527
+ "description": "Max papers to return (default 50, max 100)",
528
+ },
529
+ },
530
+ "required": ["author_name"],
531
+ },
532
+ ),
533
+ ]
534
+
535
+ # Conditional source-specific tools
536
+ if _scopus_key:
537
+ SCHOLARLY_TOOLS.append(
538
+ Tool(
539
+ name="scholarly_search_scopus",
540
+ description=(
541
+ "Search Scopus directly using Scopus query syntax (TITLE-ABS-KEY). "
542
+ "Useful for ASJC subject codes and Scopus-specific features."
543
+ ),
544
+ inputSchema={
545
+ "type": "object",
546
+ "properties": {
547
+ "query": {
548
+ "type": "string",
549
+ "description": "Search query for Scopus (TITLE-ABS-KEY syntax)",
550
+ },
551
+ "year_from": {"type": "integer", "description": "Start year"},
552
+ "year_to": {"type": "integer", "description": "End year"},
553
+ "limit": {"type": "integer", "description": "Max results (default 25)"},
554
+ },
555
+ "required": ["query"],
556
+ },
557
+ )
558
+ )
559
+
560
+ if _wos_key:
561
+ SCHOLARLY_TOOLS.append(
562
+ Tool(
563
+ name="scholarly_search_wos",
564
+ description=(
565
+ "Search Web of Science directly using WoS query syntax (TS=). "
566
+ "Useful for WoS-specific features and citation tracking."
567
+ ),
568
+ inputSchema={
569
+ "type": "object",
570
+ "properties": {
571
+ "query": {
572
+ "type": "string",
573
+ "description": "Search query for WoS (TS= syntax)",
574
+ },
575
+ "year_from": {"type": "integer", "description": "Start year"},
576
+ "year_to": {"type": "integer", "description": "End year"},
577
+ "limit": {"type": "integer", "description": "Max results (default 25)"},
578
+ },
579
+ "required": ["query"],
580
+ },
581
+ )
582
+ )
583
+
584
+
585
+ # ---------- ORCID tool definitions (researcher-centric) ----------
586
+
587
+ ORCID_TOOLS = []
588
+ if _orcid_client:
589
+ ORCID_TOOLS = [
590
+ Tool(
591
+ name="orcid_search_researchers",
592
+ description=(
593
+ "Search the ORCID registry for researchers by name, affiliation, or keyword. "
594
+ "Returns ORCID iDs with names and institutional affiliations. "
595
+ "Query syntax: family-name:Smith, given-names:John, affiliation-org-name:Warwick, keyword:MCDM. "
596
+ "Combine with AND/OR. Use this to find a researcher's ORCID iD for disambiguation."
597
+ ),
598
+ inputSchema={
599
+ "type": "object",
600
+ "properties": {
601
+ "query": {
602
+ "type": "string",
603
+ "description": "ORCID search query (Lucene syntax: family-name:X AND affiliation-org-name:Y)",
604
+ },
605
+ "limit": {
606
+ "type": "integer",
607
+ "description": "Max results (default 10, max 100)",
608
+ },
609
+ },
610
+ "required": ["query"],
611
+ },
612
+ ),
613
+ Tool(
614
+ name="orcid_get_researcher",
615
+ description=(
616
+ "Get a researcher's full ORCID profile: name, biography, affiliations, keywords, "
617
+ "URLs, and publication list with DOIs. Provide an ORCID iD (e.g. 0000-0001-2345-6789). "
618
+ "Use orcid_search_researchers first to find the iD if you only have a name."
619
+ ),
620
+ inputSchema={
621
+ "type": "object",
622
+ "properties": {
623
+ "orcid_id": {
624
+ "type": "string",
625
+ "description": "ORCID identifier (e.g. 0000-0001-2345-6789 or https://orcid.org/0000-0001-2345-6789)",
626
+ },
627
+ "include_works": {
628
+ "type": "boolean",
629
+ "description": "Include publication list (default true)",
630
+ },
631
+ "max_works": {
632
+ "type": "integer",
633
+ "description": "Max works to return (default 50)",
634
+ },
635
+ },
636
+ "required": ["orcid_id"],
637
+ },
638
+ ),
639
+ ]
640
+
641
+
642
+ # ---------- CORE tool definitions ----------
643
+
644
+ CORE_TOOLS = []
645
+ if _core_source:
646
+ CORE_TOOLS = [
647
+ Tool(
648
+ name="core_search_fulltext",
649
+ description=(
650
+ "Search CORE's 431M+ open access records. Unique: returns papers with "
651
+ "full-text content available. Use when you need actual paper text, not just metadata. "
652
+ "Supports year filtering."
653
+ ),
654
+ inputSchema={
655
+ "type": "object",
656
+ "properties": {
657
+ "query": {
658
+ "type": "string",
659
+ "description": "Search query (keywords, title fragment)",
660
+ },
661
+ "year_from": {"type": "integer", "description": "Start year"},
662
+ "year_to": {"type": "integer", "description": "End year"},
663
+ "limit": {
664
+ "type": "integer",
665
+ "description": "Max results (default 25, max 100)",
666
+ },
667
+ },
668
+ "required": ["query"],
669
+ },
670
+ ),
671
+ Tool(
672
+ name="core_get_fulltext",
673
+ description=(
674
+ "Get the full text of a paper by CORE ID. Returns the complete paper text. "
675
+ "Use core_search_fulltext first to find the CORE ID (source_id field, format: core:12345)."
676
+ ),
677
+ inputSchema={
678
+ "type": "object",
679
+ "properties": {
680
+ "core_id": {
681
+ "type": "integer",
682
+ "description": "CORE work ID (numeric, from source_id field)",
683
+ },
684
+ },
685
+ "required": ["core_id"],
686
+ },
687
+ ),
688
+ ]
689
+
690
+ # ---------- Altmetric tool definitions ----------
691
+
692
+ ALTMETRIC_TOOLS = []
693
+ if _altmetric_client:
694
+ ALTMETRIC_TOOLS = [
695
+ Tool(
696
+ name="altmetric_search",
697
+ description=(
698
+ "Search for research outputs with altmetric attention data. Returns papers "
699
+ "with their altmetric score and mention breakdown (tweets, news, blogs, policy docs, "
700
+ "Wikipedia, Reddit, Bluesky). Use to discover which papers on a topic get the most "
701
+ "real-world attention beyond citations."
702
+ ),
703
+ inputSchema={
704
+ "type": "object",
705
+ "properties": {
706
+ "query": {
707
+ "type": "string",
708
+ "description": "Search query (topic, keywords)",
709
+ },
710
+ "timeframe": {
711
+ "type": "string",
712
+ "description": "Time filter: 'all' (default), '1d', '1w', '1m', '3m', '6m', '1y'",
713
+ },
714
+ "limit": {
715
+ "type": "integer",
716
+ "description": "Max results (default 25, max 100)",
717
+ },
718
+ },
719
+ "required": ["query"],
720
+ },
721
+ ),
722
+ Tool(
723
+ name="altmetric_attention_summary",
724
+ description=(
725
+ "Get aggregate attention summary for a research topic. Returns total mentions, "
726
+ "score distribution, and top sources. Use to understand the overall attention "
727
+ "landscape for a field or topic."
728
+ ),
729
+ inputSchema={
730
+ "type": "object",
731
+ "properties": {
732
+ "query": {
733
+ "type": "string",
734
+ "description": "Topic to analyze",
735
+ },
736
+ "timeframe": {
737
+ "type": "string",
738
+ "description": "Time filter: 'all' (default), '1d', '1w', '1m', '3m', '6m', '1y'",
739
+ },
740
+ },
741
+ "required": ["query"],
742
+ },
743
+ ),
744
+ ]
745
+
746
+
747
+ # ---------- OpenReview tool definitions (always available) ----------
748
+
749
+ OPENREVIEW_TOOLS = [
750
+ Tool(
751
+ name="openreview_venue_submissions",
752
+ description=(
753
+ "Get submissions for an AI/ML conference from OpenReview. Returns titles, abstracts, "
754
+ "authors, keywords, and primary areas. Supports: NeurIPS, ICLR, ICML, ACL, EMNLP, "
755
+ "AISTATS, UAI, CoRL, AAAI. Use shorthand like 'neurips/2024' or full ID."
756
+ ),
757
+ inputSchema={
758
+ "type": "object",
759
+ "properties": {
760
+ "venue_id": {
761
+ "type": "string",
762
+ "description": "Venue ID: shorthand (neurips/2024, iclr/2025) or full (NeurIPS.cc/2024/Conference)",
763
+ },
764
+ "limit": {
765
+ "type": "integer",
766
+ "description": "Max results (default 25, max 1000)",
767
+ },
768
+ },
769
+ "required": ["venue_id"],
770
+ },
771
+ ),
772
+ Tool(
773
+ name="openreview_paper_reviews",
774
+ description=(
775
+ "Get a paper and all its reviews from OpenReview. Returns the submission plus "
776
+ "reviewer ratings, soundness, strengths, weaknesses, and questions. "
777
+ "Provide the forum ID (from openreview_venue_submissions results)."
778
+ ),
779
+ inputSchema={
780
+ "type": "object",
781
+ "properties": {
782
+ "forum_id": {
783
+ "type": "string",
784
+ "description": "OpenReview forum ID for the paper",
785
+ },
786
+ },
787
+ "required": ["forum_id"],
788
+ },
789
+ ),
790
+ Tool(
791
+ name="openreview_search",
792
+ description=(
793
+ "Search OpenReview for papers by text query. Optionally filter by venue. "
794
+ "Returns submissions matching the query. Use for finding specific papers "
795
+ "or exploring what's been submitted to a conference on a topic."
796
+ ),
797
+ inputSchema={
798
+ "type": "object",
799
+ "properties": {
800
+ "query": {
801
+ "type": "string",
802
+ "description": "Search query (keywords, title fragment)",
803
+ },
804
+ "venue_id": {
805
+ "type": "string",
806
+ "description": "Optional venue filter (e.g. neurips/2024)",
807
+ },
808
+ "limit": {
809
+ "type": "integer",
810
+ "description": "Max results (default 25)",
811
+ },
812
+ },
813
+ "required": ["query"],
814
+ },
815
+ ),
816
+ ]
817
+
818
+
819
+ # ---------- DBLP tool definitions (always available) ----------
820
+
821
+ DBLP_TOOLS = [
822
+ Tool(
823
+ name="dblp_search",
824
+ description=(
825
+ "Search DBLP for computer science publications. Covers conferences, journals, "
826
+ "books, and theses comprehensively. Free, no auth. Returns title, authors, venue, "
827
+ "year, DOI. Use for CS venue metadata and author publication lists."
828
+ ),
829
+ inputSchema={
830
+ "type": "object",
831
+ "properties": {
832
+ "query": {
833
+ "type": "string",
834
+ "description": "Search query (keywords, title, author name)",
835
+ },
836
+ "year_from": {"type": "integer", "description": "Start year filter"},
837
+ "year_to": {"type": "integer", "description": "End year filter"},
838
+ "limit": {
839
+ "type": "integer",
840
+ "description": "Max results (default 25, max 1000)",
841
+ },
842
+ },
843
+ "required": ["query"],
844
+ },
845
+ ),
846
+ ]
847
+
848
+
849
+ # ---------- OpenCitations tool definitions (always available) ----------
850
+
851
+ OPENCITATIONS_TOOLS = [
852
+ Tool(
853
+ name="opencitations_citations",
854
+ description=(
855
+ "Get papers that cite a given DOI using the fully open COCI citation index. "
856
+ "Returns citing DOIs with dates. Complements Semantic Scholar citations with "
857
+ "a fully open, non-proprietary citation graph."
858
+ ),
859
+ inputSchema={
860
+ "type": "object",
861
+ "properties": {
862
+ "doi": {
863
+ "type": "string",
864
+ "description": "DOI to find citations for (with or without prefix)",
865
+ },
866
+ "limit": {
867
+ "type": "integer",
868
+ "description": "Max results (default: all citations)",
869
+ },
870
+ },
871
+ "required": ["doi"],
872
+ },
873
+ ),
874
+ Tool(
875
+ name="opencitations_references",
876
+ description=(
877
+ "Get papers referenced by a given DOI (backward citations / bibliography). "
878
+ "Returns cited DOIs. Use for tracing intellectual lineage."
879
+ ),
880
+ inputSchema={
881
+ "type": "object",
882
+ "properties": {
883
+ "doi": {
884
+ "type": "string",
885
+ "description": "DOI to find references for",
886
+ },
887
+ "limit": {
888
+ "type": "integer",
889
+ "description": "Max results (default: all references)",
890
+ },
891
+ },
892
+ "required": ["doi"],
893
+ },
894
+ ),
895
+ ]
896
+
897
+
898
+ # ---------- Unpaywall tool definition (always available) ----------
899
+
900
+ UNPAYWALL_TOOLS = [
901
+ Tool(
902
+ name="unpaywall_find_pdf",
903
+ description=(
904
+ "Find an open access PDF for a DOI via Unpaywall. Returns the best available "
905
+ "OA link, PDF URL, OA status (gold/green/hybrid/bronze/closed), journal, and publisher. "
906
+ "Use after finding a paper to check if a free PDF is available."
907
+ ),
908
+ inputSchema={
909
+ "type": "object",
910
+ "properties": {
911
+ "doi": {
912
+ "type": "string",
913
+ "description": "DOI to find OA PDF for (with or without prefix)",
914
+ },
915
+ },
916
+ "required": ["doi"],
917
+ },
918
+ ),
919
+ ]
920
+
921
+
922
+ # ---------- Zenodo tool definitions (always available) ----------
923
+
924
+ ZENODO_TOOLS = [
925
+ Tool(
926
+ name="zenodo_search",
927
+ description=(
928
+ "Search Zenodo for research datasets, software, publications, and other outputs. "
929
+ "Filter by type: 'dataset', 'software', 'publication', 'poster', 'presentation'. "
930
+ "Use to find replication data, code repositories, and supplementary materials."
931
+ ),
932
+ inputSchema={
933
+ "type": "object",
934
+ "properties": {
935
+ "query": {
936
+ "type": "string",
937
+ "description": "Search query (keywords, topic, author name)",
938
+ },
939
+ "resource_type": {
940
+ "type": "string",
941
+ "description": "Filter: 'dataset', 'software', 'publication', 'poster', 'presentation'",
942
+ },
943
+ "limit": {
944
+ "type": "integer",
945
+ "description": "Max results (default 25, max 100)",
946
+ },
947
+ },
948
+ "required": ["query"],
949
+ },
950
+ ),
951
+ Tool(
952
+ name="zenodo_get_record",
953
+ description=(
954
+ "Get a specific Zenodo record by ID. Returns full metadata including "
955
+ "files (with download URLs), description, license, and DOI."
956
+ ),
957
+ inputSchema={
958
+ "type": "object",
959
+ "properties": {
960
+ "record_id": {
961
+ "type": "integer",
962
+ "description": "Zenodo record ID (numeric)",
963
+ },
964
+ },
965
+ "required": ["record_id"],
966
+ },
967
+ ),
968
+ ]
969
+
970
+
971
+ @server.list_tools()
972
+ async def list_tools() -> list[Tool]:
973
+ return TOOLS + SCHOLARLY_TOOLS + ORCID_TOOLS + CORE_TOOLS + ALTMETRIC_TOOLS + OPENREVIEW_TOOLS + DBLP_TOOLS + OPENCITATIONS_TOOLS + UNPAYWALL_TOOLS + ZENODO_TOOLS
974
+
975
+
976
+ # ---------- Tool handlers ----------
977
+
978
+
979
+ @server.call_tool()
980
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
981
+ log(f"call_tool: {name} {arguments}")
982
+
983
+ try:
984
+ if name == "openalex_search_works":
985
+ return await _handle_search_works(arguments)
986
+ elif name == "openalex_author_works":
987
+ return await _handle_author_works(arguments)
988
+ elif name == "openalex_author_profile":
989
+ return await _handle_author_profile(arguments)
990
+ elif name == "openalex_institution_output":
991
+ return await _handle_institution_output(arguments)
992
+ elif name == "openalex_trends":
993
+ return await _handle_trends(arguments)
994
+ elif name == "openalex_lookup_doi":
995
+ return await _handle_lookup_doi(arguments)
996
+ elif name == "openalex_citing_works":
997
+ return await _handle_citing_works(arguments)
998
+ elif name == "crossref_lookup_doi":
999
+ return await _handle_crossref_lookup_doi(arguments)
1000
+ # Scholarly (cross-source) tools
1001
+ elif name == "scholarly_search":
1002
+ return await _handle_scholarly_search(arguments)
1003
+ elif name == "scholarly_verify_dois":
1004
+ return await _handle_scholarly_verify_dois(arguments)
1005
+ elif name == "scholarly_similar_works":
1006
+ return await _handle_scholarly_similar_works(arguments)
1007
+ elif name == "scholarly_source_status":
1008
+ return await _handle_scholarly_source_status(arguments)
1009
+ elif name == "scholarly_citations":
1010
+ return await _handle_scholarly_citations(arguments)
1011
+ elif name == "scholarly_references":
1012
+ return await _handle_scholarly_references(arguments)
1013
+ elif name == "scholarly_paper_detail":
1014
+ return await _handle_scholarly_paper_detail(arguments)
1015
+ elif name == "scholarly_author_papers":
1016
+ return await _handle_scholarly_author_papers(arguments)
1017
+ elif name == "scholarly_search_scopus":
1018
+ return await _handle_scholarly_search_scopus(arguments)
1019
+ elif name == "scholarly_search_wos":
1020
+ return await _handle_scholarly_search_wos(arguments)
1021
+ # CORE tools
1022
+ elif name == "core_search_fulltext":
1023
+ return await _handle_core_search(arguments)
1024
+ elif name == "core_get_fulltext":
1025
+ return await _handle_core_get_fulltext(arguments)
1026
+ # Altmetric tools
1027
+ elif name == "altmetric_search":
1028
+ return await _handle_altmetric_search(arguments)
1029
+ elif name == "altmetric_attention_summary":
1030
+ return await _handle_altmetric_attention_summary(arguments)
1031
+ # Zenodo tools
1032
+ elif name == "zenodo_search":
1033
+ return await _handle_zenodo_search(arguments)
1034
+ elif name == "zenodo_get_record":
1035
+ return await _handle_zenodo_get_record(arguments)
1036
+ # Unpaywall tools
1037
+ elif name == "unpaywall_find_pdf":
1038
+ return await _handle_unpaywall(arguments)
1039
+ # OpenCitations tools
1040
+ elif name == "opencitations_citations":
1041
+ return await _handle_opencitations_citations(arguments)
1042
+ elif name == "opencitations_references":
1043
+ return await _handle_opencitations_references(arguments)
1044
+ # DBLP tools
1045
+ elif name == "dblp_search":
1046
+ return await _handle_dblp_search(arguments)
1047
+ # OpenReview tools
1048
+ elif name == "openreview_venue_submissions":
1049
+ return await _handle_openreview_venue(arguments)
1050
+ elif name == "openreview_paper_reviews":
1051
+ return await _handle_openreview_reviews(arguments)
1052
+ elif name == "openreview_search":
1053
+ return await _handle_openreview_search(arguments)
1054
+ # ORCID tools
1055
+ elif name == "orcid_search_researchers":
1056
+ return await _handle_orcid_search(arguments)
1057
+ elif name == "orcid_get_researcher":
1058
+ return await _handle_orcid_get_researcher(arguments)
1059
+ else:
1060
+ return [TextContent(type="text", text=f"Unknown tool: {name}")]
1061
+ except Exception as e:
1062
+ log(f"Error in {name}: {e}")
1063
+ return [TextContent(type="text", text=f"**Error:** {e}")]
1064
+
1065
+
1066
+ async def _handle_search_works(args: dict) -> list[TextContent]:
1067
+ query = args["query"]
1068
+ limit = min(args.get("limit", 25), 50)
1069
+ sort = args.get("sort", "cited_by_count:desc")
1070
+
1071
+ filter_params: dict[str, str] = {}
1072
+ if args.get("year"):
1073
+ filter_params["publication_year"] = args["year"]
1074
+ if args.get("min_citations"):
1075
+ filter_params["cited_by_count"] = f">{args['min_citations']}"
1076
+ if args.get("open_access"):
1077
+ filter_params["is_oa"] = "true"
1078
+
1079
+ def _search():
1080
+ return client.search_works(
1081
+ search=query,
1082
+ filter_params=filter_params if filter_params else None,
1083
+ per_page=limit,
1084
+ sort=sort,
1085
+ )
1086
+
1087
+ response = await asyncio.to_thread(_search)
1088
+ works = response.get("results", [])
1089
+ total = response.get("meta", {}).get("count", 0)
1090
+
1091
+ text = format_works_table(works, title=f"Search: {query}")
1092
+ text += f"\n\n*{total:,} total results in OpenAlex (showing top {len(works)})*"
1093
+ return [TextContent(type="text", text=text)]
1094
+
1095
+
1096
+ async def _handle_author_works(args: dict) -> list[TextContent]:
1097
+ author_name = args["author_name"]
1098
+ limit = min(args.get("limit", 50), 100)
1099
+
1100
+ works = await asyncio.to_thread(find_author_works, author_name, client, limit)
1101
+ text = format_works_table(works, title=f"Works by {author_name}")
1102
+ return [TextContent(type="text", text=text)]
1103
+
1104
+
1105
+ async def _handle_author_profile(args: dict) -> list[TextContent]:
1106
+ author_name = args["author_name"]
1107
+ years = args.get("years", ">2020")
1108
+
1109
+ analysis = await asyncio.to_thread(
1110
+ analyze_research_output, "author", author_name, client, years
1111
+ )
1112
+ text = format_author_profile(analysis)
1113
+ return [TextContent(type="text", text=text)]
1114
+
1115
+
1116
+ async def _handle_institution_output(args: dict) -> list[TextContent]:
1117
+ institution_name = args["institution_name"]
1118
+ years = args.get("years", ">2020")
1119
+
1120
+ analysis = await asyncio.to_thread(
1121
+ analyze_research_output, "institution", institution_name, client, years
1122
+ )
1123
+ text = format_author_profile(analysis)
1124
+ return [TextContent(type="text", text=text)]
1125
+
1126
+
1127
+ async def _handle_trends(args: dict) -> list[TextContent]:
1128
+ query = args["query"]
1129
+
1130
+ trends = await asyncio.to_thread(get_publication_trends, query, None, client)
1131
+ text = format_trends(trends, search_term=query)
1132
+ return [TextContent(type="text", text=text)]
1133
+
1134
+
1135
+ async def _handle_lookup_doi(args: dict) -> list[TextContent]:
1136
+ doi = args["doi"]
1137
+ if not doi.startswith("https://doi.org/"):
1138
+ doi = f"https://doi.org/{doi}"
1139
+
1140
+ work = await asyncio.to_thread(client.get_entity, "works", doi)
1141
+ text = format_work_detail(work)
1142
+ return [TextContent(type="text", text=text)]
1143
+
1144
+
1145
+ async def _handle_citing_works(args: dict) -> list[TextContent]:
1146
+ doi = args["doi"]
1147
+ limit = min(args.get("limit", 25), 50)
1148
+
1149
+ if not doi.startswith("https://doi.org/"):
1150
+ doi = f"https://doi.org/{doi}"
1151
+
1152
+ work = await asyncio.to_thread(client.get_entity, "works", doi)
1153
+ cited_by_url = work.get("cited_by_api_url")
1154
+
1155
+ if not cited_by_url:
1156
+ return [TextContent(type="text", text="No citation data available for this work.")]
1157
+
1158
+ import requests
1159
+
1160
+ def _fetch_citing():
1161
+ resp = requests.get(
1162
+ cited_by_url,
1163
+ params={"mailto": client.email, "per-page": limit},
1164
+ timeout=30,
1165
+ )
1166
+ resp.raise_for_status()
1167
+ return resp.json()
1168
+
1169
+ data = await asyncio.to_thread(_fetch_citing)
1170
+ citing_works = data.get("results", [])
1171
+ total = data.get("meta", {}).get("count", 0)
1172
+
1173
+ title_text = (work.get("title") or "this work")[:60]
1174
+ text = format_works_table(citing_works, title=f"Papers citing: {title_text}")
1175
+ text += f"\n\n*{total:,} total citing works (showing {len(citing_works)})*"
1176
+ return [TextContent(type="text", text=text)]
1177
+
1178
+
1179
+ async def _handle_crossref_lookup_doi(args: dict) -> list[TextContent]:
1180
+ doi = args["doi"]
1181
+ paper = await _crossref_source.verify_doi(doi)
1182
+
1183
+ if not paper:
1184
+ return [TextContent(type="text", text=f"DOI not found in Crossref: {doi}")]
1185
+
1186
+ lines = [f"## {paper.title}\n"]
1187
+ lines.append(f"**Authors:** {', '.join(paper.authors)}")
1188
+ lines.append(f"**Year:** {paper.publication_year}")
1189
+ lines.append(f"**Citations:** {paper.cited_by_count:,}")
1190
+ if paper.source_name:
1191
+ lines.append(f"**Journal:** {paper.source_name}")
1192
+ if paper.doi:
1193
+ lines.append(f"**DOI:** {paper.doi}")
1194
+ if paper.abstract:
1195
+ lines.append(f"\n**Abstract:** {paper.abstract}")
1196
+
1197
+ lines.append(f"\n*Source: Crossref (authoritative DOI registry) | Verified: Yes*")
1198
+ return [TextContent(type="text", text="\n".join(lines))]
1199
+
1200
+
1201
+ # ---------- Scholarly tool handlers ----------
1202
+
1203
+
1204
+ async def _handle_scholarly_search(args: dict) -> list[TextContent]:
1205
+ query = args["query"]
1206
+ limit = min(args.get("limit", 25), 50)
1207
+ year_from = args.get("year_from")
1208
+ year_to = args.get("year_to")
1209
+ sort_by = args.get("sort_by", "relevance")
1210
+
1211
+ if isinstance(_multi_source, MultiSource):
1212
+ _multi_source.reset_diagnostics()
1213
+
1214
+ papers = await _multi_source.search_works(
1215
+ query, year_from=year_from, year_to=year_to, sort_by=sort_by, limit=limit,
1216
+ )
1217
+ text = format_papers_table(papers, title=f"Scholarly Search: {query}")
1218
+
1219
+ if isinstance(_multi_source, MultiSource):
1220
+ diag = _multi_source.consume_diagnostics()
1221
+ if diag:
1222
+ text += f"\n\n*Sources queried: {', '.join(diag['succeeded'])}"
1223
+ if diag["failed"]:
1224
+ text += f" | Failed: {', '.join(diag['failed'])}"
1225
+ text += f" | {len(papers)} results after dedup*"
1226
+ else:
1227
+ text += f"\n\n*Source: OpenAlex | {len(papers)} results*"
1228
+
1229
+ return [TextContent(type="text", text=text)]
1230
+
1231
+
1232
+ async def _handle_scholarly_verify_dois(args: dict) -> list[TextContent]:
1233
+ dois = args["dois"]
1234
+ if len(dois) > 50:
1235
+ return [TextContent(type="text", text="**Error:** Maximum 50 DOIs per request.")]
1236
+
1237
+ results = await _multi_source.batch_verify_dois(dois)
1238
+ text = format_verification_table(results)
1239
+
1240
+ # Add source summary
1241
+ active_names = [s["name"] for s in _source_info if s["active"]]
1242
+ text += f"\n\n*Checked against: {', '.join(active_names)}*"
1243
+
1244
+ return [TextContent(type="text", text=text)]
1245
+
1246
+
1247
+ async def _handle_scholarly_similar_works(args: dict) -> list[TextContent]:
1248
+ text_query = args["text"]
1249
+ limit = min(args.get("limit", 20), 50)
1250
+
1251
+ papers = await _multi_source.find_similar_works(text_query, limit=limit)
1252
+ preview = text_query[:80] + "..." if len(text_query) > 80 else text_query
1253
+ text = format_papers_table(papers, title=f"Similar to: {preview}")
1254
+ text += f"\n\n*{len(papers)} results*"
1255
+
1256
+ return [TextContent(type="text", text=text)]
1257
+
1258
+
1259
+ async def _handle_scholarly_source_status(args: dict) -> list[TextContent]:
1260
+ text = format_source_status(_source_info)
1261
+ active_count = sum(1 for s in _source_info if s["active"])
1262
+ text += f"\n\n*{active_count}/{len(_source_info)} sources active*"
1263
+ return [TextContent(type="text", text=text)]
1264
+
1265
+
1266
+ async def _handle_scholarly_citations(args: dict) -> list[TextContent]:
1267
+ paper_id = args["paper_id"]
1268
+ limit = min(args.get("limit", 50), 1000)
1269
+
1270
+ papers = await _s2_source.get_paper_citations(paper_id, limit=limit)
1271
+ text = format_papers_table(papers, title=f"Papers citing: {paper_id}")
1272
+ text += f"\n\n*{len(papers)} citing papers (via Semantic Scholar Graph API)*"
1273
+ return [TextContent(type="text", text=text)]
1274
+
1275
+
1276
+ async def _handle_scholarly_references(args: dict) -> list[TextContent]:
1277
+ paper_id = args["paper_id"]
1278
+ limit = min(args.get("limit", 50), 1000)
1279
+
1280
+ papers = await _s2_source.get_paper_references(paper_id, limit=limit)
1281
+ text = format_papers_table(papers, title=f"References of: {paper_id}")
1282
+ text += f"\n\n*{len(papers)} references (via Semantic Scholar Graph API)*"
1283
+ return [TextContent(type="text", text=text)]
1284
+
1285
+
1286
+ async def _handle_scholarly_paper_detail(args: dict) -> list[TextContent]:
1287
+ paper_id = args["paper_id"]
1288
+
1289
+ paper = await _s2_source.get_paper_detail(paper_id)
1290
+ if not paper:
1291
+ return [TextContent(type="text", text=f"Paper not found: {paper_id}")]
1292
+
1293
+ lines = [f"## {paper.title}\n"]
1294
+ lines.append(f"**Authors:** {', '.join(paper.authors)}")
1295
+ lines.append(f"**Year:** {paper.publication_year}")
1296
+ lines.append(f"**Citations:** {paper.cited_by_count:,}")
1297
+ if paper.source_name:
1298
+ lines.append(f"**Venue:** {paper.source_name}")
1299
+ if paper.doi:
1300
+ lines.append(f"**DOI:** {paper.doi}")
1301
+ if paper.open_access_url:
1302
+ lines.append(f"**Open Access PDF:** {paper.open_access_url}")
1303
+ if paper.keywords:
1304
+ lines.append(f"**Fields:** {', '.join(paper.keywords)}")
1305
+ if paper.tldr:
1306
+ lines.append(f"\n**TLDR:** {paper.tldr}")
1307
+ if paper.abstract:
1308
+ lines.append(f"\n**Abstract:** {paper.abstract}")
1309
+ if paper.bibtex:
1310
+ lines.append(f"\n**BibTeX:**\n```bibtex\n{paper.bibtex}\n```")
1311
+
1312
+ lines.append(f"\n*Source: Semantic Scholar ({paper.source_id})*")
1313
+ return [TextContent(type="text", text="\n".join(lines))]
1314
+
1315
+
1316
+ async def _handle_scholarly_author_papers(args: dict) -> list[TextContent]:
1317
+ author_name = args["author_name"]
1318
+ limit = min(args.get("limit", 50), 100)
1319
+
1320
+ # Step 1: search for author
1321
+ authors = await _s2_source.search_author(author_name, limit=5)
1322
+ if not authors:
1323
+ return [TextContent(type="text", text=f"Author not found: {author_name}")]
1324
+
1325
+ # Pick the first match
1326
+ author = authors[0]
1327
+ author_id = author.get("authorId", "")
1328
+ display_name = author.get("name", author_name)
1329
+
1330
+ # Step 2: get their papers
1331
+ papers = await _s2_source.get_author_papers(author_id, limit=limit)
1332
+ text = format_papers_table(papers, title=f"Papers by {display_name}")
1333
+
1334
+ # Add author disambiguation info
1335
+ if len(authors) > 1:
1336
+ text += "\n\n**Other matching authors:**\n"
1337
+ for a in authors[1:5]:
1338
+ text += f"- {a.get('name', '?')} (S2 ID: {a.get('authorId', '?')})\n"
1339
+
1340
+ text += f"\n*{len(papers)} papers (via Semantic Scholar Graph API)*"
1341
+ return [TextContent(type="text", text=text)]
1342
+
1343
+
1344
+ async def _handle_scholarly_search_scopus(args: dict) -> list[TextContent]:
1345
+ query = args["query"]
1346
+ limit = min(args.get("limit", 25), 50)
1347
+ year_from = args.get("year_from")
1348
+ year_to = args.get("year_to")
1349
+
1350
+ papers = await _scopus_source.search_works(
1351
+ query, year_from=year_from, year_to=year_to, limit=limit,
1352
+ )
1353
+ text = format_papers_table(papers, title=f"Scopus: {query}")
1354
+ text += f"\n\n*{len(papers)} results from Scopus*"
1355
+
1356
+ return [TextContent(type="text", text=text)]
1357
+
1358
+
1359
+ async def _handle_scholarly_search_wos(args: dict) -> list[TextContent]:
1360
+ query = args["query"]
1361
+ limit = min(args.get("limit", 25), 50)
1362
+ year_from = args.get("year_from")
1363
+ year_to = args.get("year_to")
1364
+
1365
+ papers = await _wos_source.search_works(
1366
+ query, year_from=year_from, year_to=year_to, limit=limit,
1367
+ )
1368
+ text = format_papers_table(papers, title=f"Web of Science: {query}")
1369
+ text += f"\n\n*{len(papers)} results from WoS*"
1370
+
1371
+ return [TextContent(type="text", text=text)]
1372
+
1373
+
1374
+ # ---------- CORE tool handlers ----------
1375
+
1376
+
1377
+ async def _handle_core_search(args: dict) -> list[TextContent]:
1378
+ if not _core_source:
1379
+ return [TextContent(type="text", text="**Error:** CORE not configured (set CORE_API_KEY)")]
1380
+
1381
+ query = args["query"]
1382
+ limit = min(args.get("limit", 25), 100)
1383
+ year_from = args.get("year_from")
1384
+ year_to = args.get("year_to")
1385
+
1386
+ papers = await _core_source.search_works(
1387
+ query, year_from=year_from, year_to=year_to, limit=limit
1388
+ )
1389
+
1390
+ if not papers:
1391
+ return [TextContent(type="text", text=f"No CORE results for: {query}")]
1392
+
1393
+ text = format_papers_table(papers, title=f"CORE Search: {query}")
1394
+
1395
+ # Note which have full text available
1396
+ with_text = sum(1 for p in papers if p.open_access_url)
1397
+ text += f"\n\n*{len(papers)} results from CORE ({with_text} with full text available)*"
1398
+ text += "\n*Use `core_get_fulltext` with the CORE ID to retrieve full paper text.*"
1399
+ return [TextContent(type="text", text=text)]
1400
+
1401
+
1402
+ async def _handle_core_get_fulltext(args: dict) -> list[TextContent]:
1403
+ if not _core_source:
1404
+ return [TextContent(type="text", text="**Error:** CORE not configured (set CORE_API_KEY)")]
1405
+
1406
+ core_id = args["core_id"]
1407
+ full_text = await _core_source.get_full_text(core_id)
1408
+
1409
+ if not full_text:
1410
+ return [TextContent(type="text", text=f"No full text available for CORE ID: {core_id}")]
1411
+
1412
+ # Truncate if very long (context window safety)
1413
+ if len(full_text) > 50000:
1414
+ full_text = full_text[:50000] + f"\n\n... [truncated, {len(full_text)} chars total]"
1415
+
1416
+ return [TextContent(type="text", text=f"## Full Text (CORE ID: {core_id})\n\n{full_text}")]
1417
+
1418
+
1419
+ # ---------- Altmetric tool handlers ----------
1420
+
1421
+
1422
+ async def _handle_altmetric_search(args: dict) -> list[TextContent]:
1423
+ if not _altmetric_client:
1424
+ return [TextContent(type="text", text="**Error:** Altmetric not configured (set ALTMETRIC_API_KEY + ALTMETRIC_API_PASSWORD)")]
1425
+
1426
+ query = args["query"]
1427
+ timeframe = args.get("timeframe", "all")
1428
+ limit = min(args.get("limit", 25), 100)
1429
+
1430
+ outputs = await _altmetric_client.search(query, timeframe=timeframe, limit=limit)
1431
+
1432
+ if not outputs:
1433
+ return [TextContent(type="text", text=f"No Altmetric results for: {query}")]
1434
+
1435
+ lines = [f"## Altmetric Search: {query}\n"]
1436
+ lines.append("| Score | Title | Tweets | News | Policy | Blogs | Wikipedia | Readers |")
1437
+ lines.append("|-------|-------|--------|------|--------|-------|-----------|---------|")
1438
+
1439
+ for o in outputs:
1440
+ title = o.title[:50] + ("..." if len(o.title) > 50 else "")
1441
+ m = o.mentions
1442
+ tweets = m.get("tweet", 0)
1443
+ news = m.get("msm", 0)
1444
+ policy = m.get("policy", 0)
1445
+ blogs = m.get("blog", 0)
1446
+ wiki = m.get("wikipedia", 0)
1447
+ readers = o.readers.get("mendeley", 0)
1448
+ score = f"**{o.altmetric_score:.0f}**" if o.altmetric_score else "—"
1449
+ lines.append(f"| {score} | {title} | {tweets} | {news} | {policy} | {blogs} | {wiki} | {readers} |")
1450
+
1451
+ lines.append(f"\n*{len(outputs)} results from Altmetric Explorer (timeframe: {timeframe})*")
1452
+ return [TextContent(type="text", text="\n".join(lines))]
1453
+
1454
+
1455
+ async def _handle_altmetric_attention_summary(args: dict) -> list[TextContent]:
1456
+ if not _altmetric_client:
1457
+ return [TextContent(type="text", text="**Error:** Altmetric not configured")]
1458
+
1459
+ query = args["query"]
1460
+ timeframe = args.get("timeframe", "all")
1461
+
1462
+ data = await _altmetric_client.get_attention_summary(query, timeframe=timeframe)
1463
+
1464
+ if not data:
1465
+ return [TextContent(type="text", text=f"No attention data for: {query}")]
1466
+
1467
+ lines = [f"## Attention Summary: {query}\n"]
1468
+ lines.append(f"```json\n{__import__('json').dumps(data, indent=2)[:2000]}\n```")
1469
+ lines.append(f"\n*Source: Altmetric Explorer (timeframe: {timeframe})*")
1470
+ return [TextContent(type="text", text="\n".join(lines))]
1471
+
1472
+
1473
+ # ---------- Zenodo tool handlers ----------
1474
+
1475
+
1476
+ async def _handle_zenodo_search(args: dict) -> list[TextContent]:
1477
+ query = args["query"]
1478
+ resource_type = args.get("resource_type")
1479
+ limit = min(args.get("limit", 25), 100)
1480
+
1481
+ records = await _zenodo_client.search(query, resource_type=resource_type, limit=limit)
1482
+
1483
+ if not records:
1484
+ return [TextContent(type="text", text=f"No Zenodo results for: {query}")]
1485
+
1486
+ lines = [f"## Zenodo: {query}\n"]
1487
+ if resource_type:
1488
+ lines[0] = f"## Zenodo ({resource_type}): {query}\n"
1489
+
1490
+ lines.append("| # | Title | Type | DOI | Files | Access |")
1491
+ lines.append("|---|-------|------|-----|-------|--------|")
1492
+
1493
+ for i, r in enumerate(records, 1):
1494
+ title = r.title[:50] + ("..." if len(r.title) > 50 else "")
1495
+ doi_link = f"[{r.doi}](https://doi.org/{r.doi})" if r.doi else "—"
1496
+ file_count = len(r.files)
1497
+ file_names = ", ".join(f.filename for f in r.files[:2])
1498
+ if len(r.files) > 2:
1499
+ file_names += f"... (+{len(r.files)-2})"
1500
+ rtype = r.resource_type or "—"
1501
+ access = r.access_right or "—"
1502
+ lines.append(f"| {i} | [{title}]({r.zenodo_url}) | {rtype} | {doi_link} | {file_count}: {file_names} | {access} |")
1503
+
1504
+ lines.append(f"\n*{len(records)} results from Zenodo*")
1505
+ return [TextContent(type="text", text="\n".join(lines))]
1506
+
1507
+
1508
+ async def _handle_zenodo_get_record(args: dict) -> list[TextContent]:
1509
+ record_id = args["record_id"]
1510
+
1511
+ record = await _zenodo_client.get_record(record_id)
1512
+
1513
+ if not record:
1514
+ return [TextContent(type="text", text=f"Zenodo record not found: {record_id}")]
1515
+
1516
+ lines = [f"## {record.title}\n"]
1517
+ lines.append(f"**Zenodo:** [{record.zenodo_url}]({record.zenodo_url})")
1518
+ if record.doi:
1519
+ lines.append(f"**DOI:** [{record.doi}](https://doi.org/{record.doi})")
1520
+ if record.creators:
1521
+ lines.append(f"**Authors:** {', '.join(record.creators[:10])}")
1522
+ if record.publication_date:
1523
+ lines.append(f"**Date:** {record.publication_date}")
1524
+ if record.resource_type:
1525
+ lines.append(f"**Type:** {record.resource_type}")
1526
+ if record.access_right:
1527
+ lines.append(f"**Access:** {record.access_right}")
1528
+ if record.license:
1529
+ lines.append(f"**License:** {record.license}")
1530
+ if record.keywords:
1531
+ lines.append(f"**Keywords:** {', '.join(record.keywords)}")
1532
+ if record.description:
1533
+ lines.append(f"\n**Description:** {record.description}")
1534
+
1535
+ if record.files:
1536
+ lines.append(f"\n### Files ({len(record.files)})\n")
1537
+ for f in record.files:
1538
+ size_mb = f.size / (1024 * 1024) if f.size else 0
1539
+ size_str = f"{size_mb:.1f} MB" if size_mb >= 1 else f"{f.size:,} bytes"
1540
+ dl = f" — [download]({f.download_url})" if f.download_url else ""
1541
+ lines.append(f"- `{f.filename}` ({size_str}){dl}")
1542
+
1543
+ lines.append(f"\n*Source: Zenodo (CERN Open Repository)*")
1544
+ return [TextContent(type="text", text="\n".join(lines))]
1545
+
1546
+
1547
+ # ---------- Unpaywall tool handlers ----------
1548
+
1549
+
1550
+ async def _handle_unpaywall(args: dict) -> list[TextContent]:
1551
+ doi = args["doi"]
1552
+
1553
+ result = await _unpaywall_client.lookup(doi)
1554
+
1555
+ if not result:
1556
+ return [TextContent(type="text", text=f"DOI not found in Unpaywall: {doi}")]
1557
+
1558
+ lines = [f"## Unpaywall: {doi}\n"]
1559
+
1560
+ if result.title:
1561
+ lines.append(f"**Title:** {result.title}")
1562
+ if result.journal:
1563
+ lines.append(f"**Journal:** {result.journal}")
1564
+ if result.publisher:
1565
+ lines.append(f"**Publisher:** {result.publisher}")
1566
+
1567
+ oa_emoji = "Yes" if result.is_oa else "No"
1568
+ lines.append(f"**Open Access:** {oa_emoji}")
1569
+ if result.oa_status:
1570
+ lines.append(f"**OA Status:** {result.oa_status}")
1571
+
1572
+ if result.pdf_url:
1573
+ lines.append(f"\n**PDF:** [{result.pdf_url}]({result.pdf_url})")
1574
+ elif result.best_oa_url:
1575
+ lines.append(f"\n**Best OA Link:** [{result.best_oa_url}]({result.best_oa_url})")
1576
+ else:
1577
+ lines.append("\n*No open access version found.*")
1578
+
1579
+ return [TextContent(type="text", text="\n".join(lines))]
1580
+
1581
+
1582
+ # ---------- OpenCitations tool handlers ----------
1583
+
1584
+
1585
+ async def _handle_opencitations_citations(args: dict) -> list[TextContent]:
1586
+ doi = args["doi"]
1587
+ limit = args.get("limit")
1588
+
1589
+ citations = await _opencitations_client.get_citations(doi, limit=limit)
1590
+
1591
+ if not citations:
1592
+ return [TextContent(type="text", text=f"No citations found in OpenCitations for: {doi}")]
1593
+
1594
+ count = await _opencitations_client.get_citation_count(doi)
1595
+
1596
+ lines = [f"## OpenCitations: Papers citing {doi}\n"]
1597
+ lines.append(f"**Total citations:** {count}\n")
1598
+ lines.append("| # | Citing DOI | Date |")
1599
+ lines.append("|---|-----------|------|")
1600
+
1601
+ for i, c in enumerate(citations[:50], 1):
1602
+ citing_doi = c.citing
1603
+ date = c.creation or "—"
1604
+ lines.append(f"| {i} | [{citing_doi}](https://doi.org/{citing_doi}) | {date} |")
1605
+
1606
+ if len(citations) > 50:
1607
+ lines.append(f"\n*Showing 50 of {len(citations)} citations*")
1608
+
1609
+ lines.append(f"\n*Source: OpenCitations COCI (fully open citation index)*")
1610
+ return [TextContent(type="text", text="\n".join(lines))]
1611
+
1612
+
1613
+ async def _handle_opencitations_references(args: dict) -> list[TextContent]:
1614
+ doi = args["doi"]
1615
+ limit = args.get("limit")
1616
+
1617
+ references = await _opencitations_client.get_references(doi, limit=limit)
1618
+
1619
+ if not references:
1620
+ return [TextContent(type="text", text=f"No references found in OpenCitations for: {doi}")]
1621
+
1622
+ lines = [f"## OpenCitations: References of {doi}\n"]
1623
+ lines.append("| # | Referenced DOI | Date |")
1624
+ lines.append("|---|--------------|------|")
1625
+
1626
+ for i, r in enumerate(references, 1):
1627
+ cited_doi = r.cited
1628
+ date = r.creation or "—"
1629
+ lines.append(f"| {i} | [{cited_doi}](https://doi.org/{cited_doi}) | {date} |")
1630
+
1631
+ lines.append(f"\n*{len(references)} references (Source: OpenCitations COCI)*")
1632
+ return [TextContent(type="text", text="\n".join(lines))]
1633
+
1634
+
1635
+ # ---------- DBLP tool handlers ----------
1636
+
1637
+
1638
+ async def _handle_dblp_search(args: dict) -> list[TextContent]:
1639
+ query = args["query"]
1640
+ limit = min(args.get("limit", 25), 1000)
1641
+ year_from = args.get("year_from")
1642
+ year_to = args.get("year_to")
1643
+
1644
+ papers = await _dblp_source.search_works(
1645
+ query, year_from=year_from, year_to=year_to, limit=limit
1646
+ )
1647
+
1648
+ if not papers:
1649
+ return [TextContent(type="text", text=f"No DBLP results for: {query}")]
1650
+
1651
+ text = format_papers_table(papers, title=f"DBLP: {query}")
1652
+ text += f"\n\n*{len(papers)} results from DBLP*"
1653
+ return [TextContent(type="text", text=text)]
1654
+
1655
+
1656
+ # ---------- OpenReview tool handlers ----------
1657
+
1658
+
1659
+ async def _handle_openreview_venue(args: dict) -> list[TextContent]:
1660
+ venue_id = args["venue_id"]
1661
+ limit = min(args.get("limit", 25), 1000)
1662
+
1663
+ papers = await _openreview_client.get_venue_submissions(venue_id, limit=limit)
1664
+
1665
+ if not papers:
1666
+ return [TextContent(type="text", text=f"No submissions found for venue: {venue_id}")]
1667
+
1668
+ lines = [f"## OpenReview: {venue_id}\n"]
1669
+ lines.append("| # | Title | Authors | Keywords | Area | Forum ID |")
1670
+ lines.append("|---|-------|---------|----------|------|----------|")
1671
+
1672
+ for i, p in enumerate(papers, 1):
1673
+ title = p.title[:60] + ("..." if len(p.title) > 60 else "")
1674
+ authors = ", ".join(p.authors[:3]) + ("..." if len(p.authors) > 3 else "")
1675
+ kw = ", ".join(p.keywords[:3]) if p.keywords else "—"
1676
+ area = p.primary_area or "—"
1677
+ if len(area) > 30:
1678
+ area = area[:30] + "..."
1679
+ lines.append(f"| {i} | [{title}](https://openreview.net/forum?id={p.forum_id}) | {authors} | {kw} | {area} | `{p.forum_id}` |")
1680
+
1681
+ lines.append(f"\n*{len(papers)} submissions from OpenReview*")
1682
+ return [TextContent(type="text", text="\n".join(lines))]
1683
+
1684
+
1685
+ async def _handle_openreview_reviews(args: dict) -> list[TextContent]:
1686
+ forum_id = args["forum_id"]
1687
+
1688
+ paper = await _openreview_client.get_paper_with_reviews(forum_id)
1689
+
1690
+ if not paper:
1691
+ return [TextContent(type="text", text=f"Paper not found: {forum_id}")]
1692
+
1693
+ lines = [f"## {paper.title}\n"]
1694
+ lines.append(f"**Forum:** [openreview.net/forum?id={forum_id}](https://openreview.net/forum?id={forum_id})")
1695
+ if paper.authors:
1696
+ lines.append(f"**Authors:** {', '.join(paper.authors[:5])}")
1697
+ if paper.venue:
1698
+ lines.append(f"**Venue:** {paper.venue}")
1699
+ if paper.primary_area:
1700
+ lines.append(f"**Area:** {paper.primary_area}")
1701
+ if paper.keywords:
1702
+ lines.append(f"**Keywords:** {', '.join(paper.keywords)}")
1703
+ if paper.tldr:
1704
+ lines.append(f"**TLDR:** {paper.tldr}")
1705
+ if paper.abstract:
1706
+ lines.append(f"\n**Abstract:** {paper.abstract[:500]}{'...' if len(paper.abstract or '') > 500 else ''}")
1707
+
1708
+ if paper.reviews:
1709
+ lines.append(f"\n### Reviews ({len(paper.reviews)})\n")
1710
+ for i, r in enumerate(paper.reviews, 1):
1711
+ lines.append(f"#### Reviewer {i}")
1712
+ if r.rating:
1713
+ lines.append(f"- **Rating:** {r.rating}")
1714
+ if r.soundness:
1715
+ lines.append(f"- **Soundness:** {r.soundness}")
1716
+ if r.presentation:
1717
+ lines.append(f"- **Presentation:** {r.presentation}")
1718
+ if r.contribution:
1719
+ lines.append(f"- **Contribution:** {r.contribution}")
1720
+ if r.confidence:
1721
+ lines.append(f"- **Confidence:** {r.confidence}")
1722
+ if r.strengths:
1723
+ lines.append(f"- **Strengths:** {r.strengths}")
1724
+ if r.weaknesses:
1725
+ lines.append(f"- **Weaknesses:** {r.weaknesses}")
1726
+ lines.append("")
1727
+ else:
1728
+ lines.append("\n*No reviews available.*")
1729
+
1730
+ lines.append(f"\n*Source: OpenReview API v2*")
1731
+ return [TextContent(type="text", text="\n".join(lines))]
1732
+
1733
+
1734
+ async def _handle_openreview_search(args: dict) -> list[TextContent]:
1735
+ query = args["query"]
1736
+ venue_id = args.get("venue_id")
1737
+ limit = min(args.get("limit", 25), 100)
1738
+
1739
+ papers = await _openreview_client.search(query, venue_id=venue_id, limit=limit)
1740
+
1741
+ if not papers:
1742
+ return [TextContent(type="text", text=f"No OpenReview results for: {query}")]
1743
+
1744
+ lines = [f"## OpenReview Search: {query}\n"]
1745
+ if venue_id:
1746
+ lines[0] = f"## OpenReview Search: {query} (venue: {venue_id})\n"
1747
+
1748
+ lines.append("| # | Title | Authors | Venue | Forum ID |")
1749
+ lines.append("|---|-------|---------|-------|----------|")
1750
+
1751
+ for i, p in enumerate(papers, 1):
1752
+ title = p.title[:60] + ("..." if len(p.title) > 60 else "")
1753
+ authors = ", ".join(p.authors[:2]) + ("..." if len(p.authors) > 2 else "")
1754
+ venue = p.venue or p.venue_id or "—"
1755
+ if len(venue) > 30:
1756
+ venue = venue[:30] + "..."
1757
+ lines.append(f"| {i} | [{title}](https://openreview.net/forum?id={p.forum_id}) | {authors} | {venue} | `{p.forum_id}` |")
1758
+
1759
+ lines.append(f"\n*{len(papers)} results from OpenReview*")
1760
+ return [TextContent(type="text", text="\n".join(lines))]
1761
+
1762
+
1763
+ # ---------- ORCID tool handlers ----------
1764
+
1765
+
1766
+ async def _handle_orcid_search(args: dict) -> list[TextContent]:
1767
+ if not _orcid_client:
1768
+ return [TextContent(type="text", text="**Error:** ORCID not configured (set ORCID_CLIENT_ID + ORCID_CLIENT_SECRET)")]
1769
+
1770
+ query = args["query"]
1771
+ limit = min(args.get("limit", 10), 100)
1772
+
1773
+ results = await _orcid_client.search(query, limit=limit)
1774
+
1775
+ if not results:
1776
+ return [TextContent(type="text", text=f"No ORCID profiles found for: {query}")]
1777
+
1778
+ lines = [f"## ORCID Search: {query}\n"]
1779
+ lines.append(f"| # | ORCID iD | Name | Affiliations |")
1780
+ lines.append(f"|---|----------|------|-------------|")
1781
+
1782
+ for i, r in enumerate(results, 1):
1783
+ name = r.credit_name or f"{r.given_names} {r.family_name}"
1784
+ institutions = ", ".join(r.institutions[:3]) if r.institutions else "—"
1785
+ lines.append(f"| {i} | [{r.orcid_id}](https://orcid.org/{r.orcid_id}) | {name} | {institutions} |")
1786
+
1787
+ lines.append(f"\n*{len(results)} result(s) from ORCID registry*")
1788
+ return [TextContent(type="text", text="\n".join(lines))]
1789
+
1790
+
1791
+ async def _handle_orcid_get_researcher(args: dict) -> list[TextContent]:
1792
+ if not _orcid_client:
1793
+ return [TextContent(type="text", text="**Error:** ORCID not configured (set ORCID_CLIENT_ID + ORCID_CLIENT_SECRET)")]
1794
+
1795
+ orcid_id = args["orcid_id"]
1796
+ include_works = args.get("include_works", True)
1797
+ max_works = min(args.get("max_works", 50), 200)
1798
+
1799
+ researcher = await _orcid_client.get_researcher(
1800
+ orcid_id,
1801
+ include_works=include_works,
1802
+ max_works=max_works,
1803
+ )
1804
+
1805
+ if not researcher:
1806
+ return [TextContent(type="text", text=f"ORCID profile not found: {orcid_id}")]
1807
+
1808
+ lines = [f"## {researcher.display_name}\n"]
1809
+ lines.append(f"**ORCID:** [{researcher.orcid_id}]({researcher.profile_url})")
1810
+
1811
+ if researcher.affiliations:
1812
+ lines.append(f"**Affiliations:** {', '.join(researcher.affiliations)}")
1813
+
1814
+ if researcher.biography:
1815
+ bio = researcher.biography[:500]
1816
+ if len(researcher.biography) > 500:
1817
+ bio += "..."
1818
+ lines.append(f"\n**Biography:** {bio}")
1819
+
1820
+ if researcher.keywords:
1821
+ lines.append(f"**Keywords:** {', '.join(researcher.keywords)}")
1822
+
1823
+ if researcher.urls:
1824
+ url_parts = [f"[{name}]({url})" for name, url in list(researcher.urls.items())[:5]]
1825
+ lines.append(f"**Links:** {' · '.join(url_parts)}")
1826
+
1827
+ if researcher.works:
1828
+ lines.append(f"\n### Publications ({researcher.works_count} total, showing {len(researcher.works)})\n")
1829
+ lines.append("| Year | Title | DOI | Type |")
1830
+ lines.append("|------|-------|-----|------|")
1831
+
1832
+ for w in sorted(researcher.works, key=lambda x: x.year or 0, reverse=True):
1833
+ year = str(w.year) if w.year else "—"
1834
+ title = w.title[:80] + ("..." if len(w.title) > 80 else "")
1835
+ doi_link = f"[{w.doi}](https://doi.org/{w.doi})" if w.doi else "—"
1836
+ wtype = (w.work_type or "—").replace("-", " ")
1837
+ lines.append(f"| {year} | {title} | {doi_link} | {wtype} |")
1838
+
1839
+ lines.append(f"\n*Source: ORCID Public API v3.0*")
1840
+ return [TextContent(type="text", text="\n".join(lines))]
1841
+
1842
+
1843
+ # ---------- Main ----------
1844
+
1845
+ async def main():
1846
+ log("Starting MCP server...")
1847
+ async with stdio_server() as (read_stream, write_stream):
1848
+ log("stdio_server ready, running server...")
1849
+ await server.run(
1850
+ read_stream, write_stream, server.create_initialization_options()
1851
+ )
1852
+ log("Server stopped")
1853
+
1854
+
1855
+ if __name__ == "__main__":
1856
+ log("Main entry point")
1857
+ asyncio.run(main())