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.
- package/.claude/agents/domain-reviewer.md +336 -0
- package/.claude/agents/fixer.md +226 -0
- package/.claude/agents/paper-critic.md +370 -0
- package/.claude/agents/peer-reviewer.md +289 -0
- package/.claude/agents/proposal-reviewer.md +215 -0
- package/.claude/agents/referee2-reviewer.md +367 -0
- package/.claude/agents/references/journal-referee-profiles.md +354 -0
- package/.claude/agents/references/paper-critic/council-personas.md +77 -0
- package/.claude/agents/references/paper-critic/council-prompts.md +198 -0
- package/.claude/agents/references/peer-reviewer/report-template.md +199 -0
- package/.claude/agents/references/peer-reviewer/sa-prompts.md +260 -0
- package/.claude/agents/references/peer-reviewer/security-scan.md +188 -0
- package/.claude/agents/references/proposal-reviewer/report-template.md +144 -0
- package/.claude/agents/references/proposal-reviewer/sa-prompts.md +149 -0
- package/.claude/agents/references/referee-config.md +114 -0
- package/.claude/agents/references/referee2-reviewer/audit-checklists.md +287 -0
- package/.claude/agents/references/referee2-reviewer/report-template.md +334 -0
- package/.claude/rules/design-before-results.md +52 -0
- package/.claude/rules/ignore-agents-md.md +17 -0
- package/.claude/rules/ignore-gemini-md.md +17 -0
- package/.claude/rules/lean-claude-md.md +45 -0
- package/.claude/rules/learn-tags.md +99 -0
- package/.claude/rules/overleaf-separation.md +67 -0
- package/.claude/rules/plan-first.md +175 -0
- package/.claude/rules/read-docs-first.md +50 -0
- package/.claude/rules/scope-discipline.md +28 -0
- package/.claude/settings.json +125 -0
- package/.context/current-focus.md +33 -0
- package/.context/preferences/priorities.md +36 -0
- package/.context/preferences/task-naming.md +28 -0
- package/.context/profile.md +29 -0
- package/.context/projects/_index.md +41 -0
- package/.context/projects/papers/nudge-exp.md +22 -0
- package/.context/projects/papers/uncertainty.md +31 -0
- package/.context/resources/claude-scientific-writer-review.md +48 -0
- package/.context/resources/cunningham-multi-analyst-agents.md +104 -0
- package/.context/resources/cunningham-multilang-code-audit.md +62 -0
- package/.context/resources/google-ai-co-scientist-review.md +72 -0
- package/.context/resources/karpathy-llm-council-review.md +58 -0
- package/.context/resources/multi-coder-reliability-protocol.md +175 -0
- package/.context/resources/pedro-santanna-takeaways.md +96 -0
- package/.context/resources/venue-rankings/abs_ajg_2024.csv +1823 -0
- package/.context/resources/venue-rankings/abs_ajg_2024_econ.csv +356 -0
- package/.context/resources/venue-rankings/cabs_4_4star_theory.csv +40 -0
- package/.context/resources/venue-rankings/core_2026.csv +801 -0
- package/.context/resources/venue-rankings.md +147 -0
- package/.context/workflows/README.md +69 -0
- package/.context/workflows/daily-review.md +91 -0
- package/.context/workflows/meeting-actions.md +108 -0
- package/.context/workflows/replication-protocol.md +155 -0
- package/.context/workflows/weekly-review.md +113 -0
- package/.mcp-server-biblio/formatters.py +158 -0
- package/.mcp-server-biblio/pyproject.toml +11 -0
- package/.mcp-server-biblio/server.py +678 -0
- package/.mcp-server-biblio/sources/__init__.py +14 -0
- package/.mcp-server-biblio/sources/base.py +73 -0
- package/.mcp-server-biblio/sources/formatters.py +83 -0
- package/.mcp-server-biblio/sources/models.py +22 -0
- package/.mcp-server-biblio/sources/multi_source.py +243 -0
- package/.mcp-server-biblio/sources/openalex_source.py +183 -0
- package/.mcp-server-biblio/sources/scopus_source.py +309 -0
- package/.mcp-server-biblio/sources/wos_source.py +508 -0
- package/.mcp-server-biblio/uv.lock +896 -0
- package/.scripts/README.md +161 -0
- package/.scripts/ai_pattern_density.py +446 -0
- package/.scripts/conf +445 -0
- package/.scripts/config.py +122 -0
- package/.scripts/count_inventory.py +275 -0
- package/.scripts/daily_digest.py +288 -0
- package/.scripts/done +177 -0
- package/.scripts/extract_meeting_actions.py +223 -0
- package/.scripts/focus +176 -0
- package/.scripts/generate-codex-agents-md.py +217 -0
- package/.scripts/inbox +194 -0
- package/.scripts/notion_helpers.py +325 -0
- package/.scripts/openalex/query_helpers.py +306 -0
- package/.scripts/papers +227 -0
- package/.scripts/query +223 -0
- package/.scripts/session-history.py +201 -0
- package/.scripts/skill-health.py +516 -0
- package/.scripts/skill-log-miner.py +273 -0
- package/.scripts/sync-to-codex.sh +252 -0
- package/.scripts/task +213 -0
- package/.scripts/tasks +190 -0
- package/.scripts/week +206 -0
- package/CLAUDE.md +197 -0
- package/LICENSE +21 -0
- package/MEMORY.md +38 -0
- package/README.md +269 -0
- package/docs/agents.md +44 -0
- package/docs/bibliography-setup.md +55 -0
- package/docs/council-mode.md +36 -0
- package/docs/getting-started.md +245 -0
- package/docs/hooks.md +38 -0
- package/docs/mcp-servers.md +82 -0
- package/docs/notion-setup.md +109 -0
- package/docs/rules.md +33 -0
- package/docs/scripts.md +303 -0
- package/docs/setup-overview/setup-overview.pdf +0 -0
- package/docs/skills.md +70 -0
- package/docs/system.md +159 -0
- package/hooks/block-destructive-git.sh +66 -0
- package/hooks/context-monitor.py +114 -0
- package/hooks/postcompact-restore.py +157 -0
- package/hooks/precompact-autosave.py +181 -0
- package/hooks/promise-checker.sh +124 -0
- package/hooks/protect-source-files.sh +81 -0
- package/hooks/resume-context-loader.sh +53 -0
- package/hooks/startup-context-loader.sh +102 -0
- package/package.json +51 -0
- package/packages/cli-council/.github/workflows/claude-code-review.yml +44 -0
- package/packages/cli-council/.github/workflows/claude.yml +50 -0
- package/packages/cli-council/README.md +100 -0
- package/packages/cli-council/pyproject.toml +43 -0
- package/packages/cli-council/src/cli_council/__init__.py +19 -0
- package/packages/cli-council/src/cli_council/__main__.py +185 -0
- package/packages/cli-council/src/cli_council/backends/__init__.py +8 -0
- package/packages/cli-council/src/cli_council/backends/base.py +81 -0
- package/packages/cli-council/src/cli_council/backends/claude.py +25 -0
- package/packages/cli-council/src/cli_council/backends/codex.py +27 -0
- package/packages/cli-council/src/cli_council/backends/gemini.py +26 -0
- package/packages/cli-council/src/cli_council/checkpoint.py +212 -0
- package/packages/cli-council/src/cli_council/config.py +51 -0
- package/packages/cli-council/src/cli_council/council.py +391 -0
- package/packages/cli-council/src/cli_council/models.py +46 -0
- package/packages/llm-council/.github/workflows/claude-code-review.yml +44 -0
- package/packages/llm-council/.github/workflows/claude.yml +50 -0
- package/packages/llm-council/README.md +453 -0
- package/packages/llm-council/pyproject.toml +42 -0
- package/packages/llm-council/src/llm_council/__init__.py +23 -0
- package/packages/llm-council/src/llm_council/__main__.py +259 -0
- package/packages/llm-council/src/llm_council/checkpoint.py +193 -0
- package/packages/llm-council/src/llm_council/client.py +253 -0
- package/packages/llm-council/src/llm_council/config.py +232 -0
- package/packages/llm-council/src/llm_council/council.py +482 -0
- package/packages/llm-council/src/llm_council/models.py +46 -0
- package/packages/mcp-bibliography/MEMORY.md +31 -0
- package/packages/mcp-bibliography/_app.py +226 -0
- package/packages/mcp-bibliography/formatters.py +158 -0
- package/packages/mcp-bibliography/log/2026-03-13-2100.md +35 -0
- package/packages/mcp-bibliography/pyproject.toml +15 -0
- package/packages/mcp-bibliography/run.sh +20 -0
- package/packages/mcp-bibliography/scholarly_formatters.py +83 -0
- package/packages/mcp-bibliography/server.py +1857 -0
- package/packages/mcp-bibliography/tools/__init__.py +28 -0
- package/packages/mcp-bibliography/tools/_registry.py +19 -0
- package/packages/mcp-bibliography/tools/altmetric.py +107 -0
- package/packages/mcp-bibliography/tools/core.py +92 -0
- package/packages/mcp-bibliography/tools/dblp.py +52 -0
- package/packages/mcp-bibliography/tools/openalex.py +296 -0
- package/packages/mcp-bibliography/tools/opencitations.py +102 -0
- package/packages/mcp-bibliography/tools/openreview.py +179 -0
- package/packages/mcp-bibliography/tools/orcid.py +131 -0
- package/packages/mcp-bibliography/tools/scholarly.py +575 -0
- package/packages/mcp-bibliography/tools/unpaywall.py +63 -0
- package/packages/mcp-bibliography/tools/zenodo.py +123 -0
- package/packages/mcp-bibliography/uv.lock +711 -0
- package/scripts/setup.sh +143 -0
- package/skills/beamer-deck/SKILL.md +199 -0
- package/skills/beamer-deck/references/quality-rubric.md +54 -0
- package/skills/beamer-deck/references/review-prompts.md +106 -0
- package/skills/bib-validate/SKILL.md +261 -0
- package/skills/bib-validate/references/council-mode.md +34 -0
- package/skills/bib-validate/references/deep-verify.md +79 -0
- package/skills/bib-validate/references/fix-mode.md +36 -0
- package/skills/bib-validate/references/openalex-verification.md +45 -0
- package/skills/bib-validate/references/preprint-check.md +31 -0
- package/skills/bib-validate/references/ref-manager-crossref.md +41 -0
- package/skills/bib-validate/references/report-template.md +82 -0
- package/skills/code-archaeology/SKILL.md +141 -0
- package/skills/code-review/SKILL.md +265 -0
- package/skills/code-review/references/quality-rubric.md +67 -0
- package/skills/consolidate-memory/SKILL.md +208 -0
- package/skills/context-status/SKILL.md +126 -0
- package/skills/creation-guard/SKILL.md +230 -0
- package/skills/devils-advocate/SKILL.md +130 -0
- package/skills/devils-advocate/references/competing-hypotheses.md +83 -0
- package/skills/init-project/SKILL.md +115 -0
- package/skills/init-project-course/references/memory-and-settings.md +92 -0
- package/skills/init-project-course/references/organise-templates.md +94 -0
- package/skills/init-project-course/skill.md +147 -0
- package/skills/init-project-light/skill.md +139 -0
- package/skills/init-project-research/SKILL.md +368 -0
- package/skills/init-project-research/references/atlas-pipeline-sync.md +70 -0
- package/skills/init-project-research/references/atlas-schema.md +81 -0
- package/skills/init-project-research/references/confirmation-report.md +39 -0
- package/skills/init-project-research/references/domain-profile-template.md +104 -0
- package/skills/init-project-research/references/interview-round3.md +34 -0
- package/skills/init-project-research/references/literature-discovery.md +43 -0
- package/skills/init-project-research/references/scaffold-details.md +197 -0
- package/skills/init-project-research/templates/field-calibration.md +60 -0
- package/skills/init-project-research/templates/pipeline-manifest.md +63 -0
- package/skills/init-project-research/templates/run-all.sh +116 -0
- package/skills/init-project-research/templates/seed-files.md +337 -0
- package/skills/insights-deck/SKILL.md +151 -0
- package/skills/interview-me/SKILL.md +157 -0
- package/skills/latex/SKILL.md +141 -0
- package/skills/latex/references/latex-configs.md +183 -0
- package/skills/latex-autofix/SKILL.md +230 -0
- package/skills/latex-autofix/references/known-errors.md +183 -0
- package/skills/latex-autofix/references/quality-rubric.md +50 -0
- package/skills/latex-health-check/SKILL.md +161 -0
- package/skills/learn/SKILL.md +220 -0
- package/skills/learn/scripts/validate_skill.py +265 -0
- package/skills/lessons-learned/SKILL.md +201 -0
- package/skills/literature/SKILL.md +335 -0
- package/skills/literature/references/agent-templates.md +393 -0
- package/skills/literature/references/bibliometric-apis.md +44 -0
- package/skills/literature/references/cli-council-search.md +79 -0
- package/skills/literature/references/openalex-api-guide.md +371 -0
- package/skills/literature/references/openalex-common-queries.md +381 -0
- package/skills/literature/references/openalex-workflows.md +248 -0
- package/skills/literature/references/reference-manager-sync.md +36 -0
- package/skills/literature/references/scopus-api-guide.md +208 -0
- package/skills/literature/references/wos-api-guide.md +308 -0
- package/skills/multi-perspective/SKILL.md +311 -0
- package/skills/multi-perspective/references/computational-many-analysts.md +77 -0
- package/skills/pipeline-manifest/SKILL.md +226 -0
- package/skills/pre-submission-report/SKILL.md +153 -0
- package/skills/process-reviews/SKILL.md +244 -0
- package/skills/process-reviews/references/rr-routing.md +101 -0
- package/skills/project-deck/SKILL.md +87 -0
- package/skills/project-safety/SKILL.md +135 -0
- package/skills/proofread/SKILL.md +254 -0
- package/skills/proofread/references/quality-rubric.md +104 -0
- package/skills/python-env/SKILL.md +57 -0
- package/skills/quarto-deck/SKILL.md +226 -0
- package/skills/quarto-deck/references/markdown-format.md +143 -0
- package/skills/quarto-deck/references/quality-rubric.md +54 -0
- package/skills/save-context/SKILL.md +174 -0
- package/skills/session-log/SKILL.md +98 -0
- package/skills/shared/concept-validation-gate.md +161 -0
- package/skills/shared/council-protocol.md +265 -0
- package/skills/shared/distribution-diagnostics.md +164 -0
- package/skills/shared/engagement-stratified-sampling.md +218 -0
- package/skills/shared/escalation-protocol.md +74 -0
- package/skills/shared/external-audit-protocol.md +205 -0
- package/skills/shared/intercoder-reliability.md +256 -0
- package/skills/shared/mcp-degradation.md +81 -0
- package/skills/shared/method-probing-questions.md +163 -0
- package/skills/shared/multi-language-conventions.md +143 -0
- package/skills/shared/paid-api-safety.md +174 -0
- package/skills/shared/palettes.md +90 -0
- package/skills/shared/progressive-disclosure.md +92 -0
- package/skills/shared/project-documentation-content.md +443 -0
- package/skills/shared/project-documentation-format.md +281 -0
- package/skills/shared/project-documentation.md +100 -0
- package/skills/shared/publication-output.md +138 -0
- package/skills/shared/quality-scoring.md +70 -0
- package/skills/shared/reference-resolution.md +77 -0
- package/skills/shared/research-quality-rubric.md +165 -0
- package/skills/shared/rhetoric-principles.md +54 -0
- package/skills/shared/skill-design-patterns.md +272 -0
- package/skills/shared/skill-index.md +240 -0
- package/skills/shared/system-documentation.md +334 -0
- package/skills/shared/tikz-rules.md +402 -0
- package/skills/shared/validation-tiers.md +121 -0
- package/skills/shared/venue-guides/README.md +46 -0
- package/skills/shared/venue-guides/cell_press_style.md +483 -0
- package/skills/shared/venue-guides/conferences_formatting.md +564 -0
- package/skills/shared/venue-guides/cs_conference_style.md +463 -0
- package/skills/shared/venue-guides/examples/cell_summary_example.md +247 -0
- package/skills/shared/venue-guides/examples/medical_structured_abstract.md +313 -0
- package/skills/shared/venue-guides/examples/nature_abstract_examples.md +213 -0
- package/skills/shared/venue-guides/examples/neurips_introduction_example.md +245 -0
- package/skills/shared/venue-guides/journals_formatting.md +486 -0
- package/skills/shared/venue-guides/medical_journal_styles.md +535 -0
- package/skills/shared/venue-guides/ml_conference_style.md +556 -0
- package/skills/shared/venue-guides/nature_science_style.md +405 -0
- package/skills/shared/venue-guides/reviewer_expectations.md +417 -0
- package/skills/shared/venue-guides/venue_writing_styles.md +321 -0
- package/skills/split-pdf/SKILL.md +172 -0
- package/skills/split-pdf/methodology.md +48 -0
- package/skills/sync-notion/SKILL.md +93 -0
- package/skills/system-audit/SKILL.md +157 -0
- package/skills/system-audit/references/sub-agent-prompts.md +294 -0
- package/skills/task-management/SKILL.md +131 -0
- package/skills/update-focus/SKILL.md +204 -0
- package/skills/update-project-doc/SKILL.md +194 -0
- package/skills/validate-bib/SKILL.md +242 -0
- package/skills/validate-bib/references/council-mode.md +34 -0
- package/skills/validate-bib/references/deep-verify.md +71 -0
- package/skills/validate-bib/references/openalex-verification.md +45 -0
- package/skills/validate-bib/references/preprint-check.md +31 -0
- package/skills/validate-bib/references/report-template.md +62 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Biblio MCP Server — shared state
|
|
3
|
+
|
|
4
|
+
Server instance, client initialization, formatters, and helpers.
|
|
5
|
+
Imported by tool modules in tools/.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import unicodedata
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from mcp.server import Server
|
|
16
|
+
from mcp.types import Tool, TextContent
|
|
17
|
+
|
|
18
|
+
from biblio_sources import (
|
|
19
|
+
AltmetricClient,
|
|
20
|
+
CoreSource,
|
|
21
|
+
CrossrefSource,
|
|
22
|
+
DblpSource,
|
|
23
|
+
OpenCitationsClient,
|
|
24
|
+
OpenReviewClient,
|
|
25
|
+
UnpaywallClient,
|
|
26
|
+
ZenodoClient,
|
|
27
|
+
OrcidClient,
|
|
28
|
+
OpenAlexClient,
|
|
29
|
+
OpenAlexSource,
|
|
30
|
+
MultiSource,
|
|
31
|
+
SemanticScholarSource,
|
|
32
|
+
ScopusSource,
|
|
33
|
+
WosSource,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# OpenAlex-specific helpers (raw dict API)
|
|
37
|
+
SCRIPTS_DIR = str(Path(__file__).parent.parent.parent / ".scripts" / "openalex")
|
|
38
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
39
|
+
|
|
40
|
+
from query_helpers import ( # noqa: E402
|
|
41
|
+
find_author_works,
|
|
42
|
+
analyze_research_output,
|
|
43
|
+
get_publication_trends,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
from formatters import ( # noqa: E402
|
|
47
|
+
format_works_table,
|
|
48
|
+
format_author_profile,
|
|
49
|
+
format_trends,
|
|
50
|
+
format_work_detail,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
from scholarly_formatters import ( # noqa: E402
|
|
54
|
+
format_papers_table,
|
|
55
|
+
format_verification_table,
|
|
56
|
+
format_source_status,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def log(msg):
|
|
61
|
+
print(f"[bibliography-mcp] {msg}", file=sys.stderr, flush=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------- Client / source initialization ----------
|
|
65
|
+
|
|
66
|
+
# Shared client instance (polite pool)
|
|
67
|
+
client = OpenAlexClient(email="user@example.com")
|
|
68
|
+
|
|
69
|
+
_all_sources = []
|
|
70
|
+
_source_info = []
|
|
71
|
+
|
|
72
|
+
# OpenAlex — always available
|
|
73
|
+
_openalex_source = OpenAlexSource(client)
|
|
74
|
+
_all_sources.append(_openalex_source)
|
|
75
|
+
_source_info.append({"name": "OpenAlex", "key": "openalex", "active": True})
|
|
76
|
+
log("OpenAlex source: active")
|
|
77
|
+
|
|
78
|
+
# Semantic Scholar — always available, optional API key for higher rate limits
|
|
79
|
+
_s2_key = os.environ.get("S2_API_KEY")
|
|
80
|
+
_s2_source = SemanticScholarSource(api_key=_s2_key)
|
|
81
|
+
_all_sources.append(_s2_source)
|
|
82
|
+
_source_info.append({"name": "Semantic Scholar", "key": "s2", "active": True})
|
|
83
|
+
log(f"Semantic Scholar source: active{' (API key)' if _s2_key else ' (no key, 1 req/sec)'}")
|
|
84
|
+
|
|
85
|
+
# Crossref — always available (no API key, polite pool via mailto)
|
|
86
|
+
_crossref_source = CrossrefSource(mailto="user@example.com")
|
|
87
|
+
_all_sources.append(_crossref_source)
|
|
88
|
+
_source_info.append({"name": "Crossref", "key": "crossref", "active": True})
|
|
89
|
+
log("Crossref source: active (authoritative DOI registry)")
|
|
90
|
+
|
|
91
|
+
# Scopus — optional, requires SCOPUS_API_KEY
|
|
92
|
+
_scopus_key = os.environ.get("SCOPUS_API_KEY")
|
|
93
|
+
_scopus_source = None
|
|
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_source = None
|
|
107
|
+
_wos_tier = os.environ.get("WOS_API_TIER", "starter").lower()
|
|
108
|
+
if _wos_key:
|
|
109
|
+
_wos_source = WosSource(_wos_key, tier=_wos_tier)
|
|
110
|
+
_all_sources.append(_wos_source)
|
|
111
|
+
_source_info.append({"name": f"Web of Science ({_wos_tier})", "key": "wos", "active": True})
|
|
112
|
+
log(f"WoS source: active (tier={_wos_tier})")
|
|
113
|
+
else:
|
|
114
|
+
_source_info.append({"name": "Web of Science", "key": "wos", "active": False})
|
|
115
|
+
log("WoS source: no API key")
|
|
116
|
+
|
|
117
|
+
# Composite source for cross-source queries
|
|
118
|
+
_multi_source = MultiSource(_all_sources) if len(_all_sources) > 1 else _openalex_source
|
|
119
|
+
log(f"Multi-source: {len(_all_sources)} source(s) active")
|
|
120
|
+
|
|
121
|
+
# CORE — open access full text, optional API key
|
|
122
|
+
_core_key = os.environ.get("CORE_API_KEY", "")
|
|
123
|
+
_core_source = None
|
|
124
|
+
if _core_key:
|
|
125
|
+
_core_source = CoreSource(api_key=_core_key)
|
|
126
|
+
_all_sources.append(_core_source)
|
|
127
|
+
_source_info.append({"name": "CORE", "key": "core", "active": True})
|
|
128
|
+
log("CORE source: active (431M+ records, full-text access)")
|
|
129
|
+
else:
|
|
130
|
+
_source_info.append({"name": "CORE", "key": "core", "active": False})
|
|
131
|
+
log("CORE source: no API key (set CORE_API_KEY)")
|
|
132
|
+
|
|
133
|
+
# ORCID — researcher profiles, always available if credentials set
|
|
134
|
+
_orcid_client_id = os.environ.get("ORCID_CLIENT_ID", "")
|
|
135
|
+
_orcid_client_secret = os.environ.get("ORCID_CLIENT_SECRET", "")
|
|
136
|
+
_orcid_client = None
|
|
137
|
+
if _orcid_client_id and _orcid_client_secret:
|
|
138
|
+
_orcid_client = OrcidClient(
|
|
139
|
+
client_id=_orcid_client_id,
|
|
140
|
+
client_secret=_orcid_client_secret,
|
|
141
|
+
)
|
|
142
|
+
log("ORCID client: active")
|
|
143
|
+
else:
|
|
144
|
+
log("ORCID client: no credentials (set ORCID_CLIENT_ID + ORCID_CLIENT_SECRET)")
|
|
145
|
+
|
|
146
|
+
# Altmetric Explorer — research attention metrics, optional
|
|
147
|
+
_altmetric_key = os.environ.get("ALTMETRIC_API_KEY", "")
|
|
148
|
+
_altmetric_secret = os.environ.get("ALTMETRIC_API_PASSWORD", "")
|
|
149
|
+
_altmetric_client = None
|
|
150
|
+
if _altmetric_key and _altmetric_secret:
|
|
151
|
+
_altmetric_client = AltmetricClient(api_key=_altmetric_key, api_secret=_altmetric_secret)
|
|
152
|
+
log("Altmetric Explorer client: active")
|
|
153
|
+
else:
|
|
154
|
+
log("Altmetric Explorer client: no credentials (set ALTMETRIC_API_KEY + ALTMETRIC_API_PASSWORD)")
|
|
155
|
+
|
|
156
|
+
# Zenodo — research data repository, always available (no auth)
|
|
157
|
+
_zenodo_client = ZenodoClient()
|
|
158
|
+
log("Zenodo client: active (no auth required)")
|
|
159
|
+
|
|
160
|
+
# Unpaywall — OA PDF resolver, always available (no auth, just email)
|
|
161
|
+
_unpaywall_client = UnpaywallClient(email="user@example.com")
|
|
162
|
+
log("Unpaywall client: active (no auth required)")
|
|
163
|
+
|
|
164
|
+
# OpenCitations — open citation graph, always available (no auth)
|
|
165
|
+
_opencitations_client = OpenCitationsClient()
|
|
166
|
+
log("OpenCitations client: active (no auth required)")
|
|
167
|
+
|
|
168
|
+
# DBLP — CS publications, always available (no auth)
|
|
169
|
+
_dblp_source = DblpSource()
|
|
170
|
+
log("DBLP source: active (no auth required)")
|
|
171
|
+
|
|
172
|
+
# OpenReview — conference submissions and reviews, always available (no auth)
|
|
173
|
+
_openreview_client = OpenReviewClient()
|
|
174
|
+
log("OpenReview client: active (no auth required)")
|
|
175
|
+
|
|
176
|
+
# ---------- Utility: BibTeX key generation ----------
|
|
177
|
+
|
|
178
|
+
_KEY_STOPWORDS = frozenset({
|
|
179
|
+
"a", "an", "the", "and", "or", "of", "in", "on", "for", "to", "with", "by",
|
|
180
|
+
"from", "at", "is", "are", "do", "does", "how", "what", "when", "where",
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def generate_bibtex_key(authors: list[str], year: int | None, title: str | None) -> str:
|
|
185
|
+
"""Generate a BibTeX key following Google Scholar convention: surnameYearFirstword.
|
|
186
|
+
|
|
187
|
+
Examples:
|
|
188
|
+
["Albert Einstein"], 1905, "On the Electrodynamics..." -> einstein1905electrodynamics
|
|
189
|
+
["María García-López"], 2023, "A Study..." -> garcialopez2023study
|
|
190
|
+
"""
|
|
191
|
+
# Surname: last token of first author, ASCII-normalized, lowercase
|
|
192
|
+
surname = ""
|
|
193
|
+
if authors:
|
|
194
|
+
name = authors[0].strip()
|
|
195
|
+
# Handle "Last, First" format
|
|
196
|
+
if "," in name:
|
|
197
|
+
surname = name.split(",")[0].strip()
|
|
198
|
+
else:
|
|
199
|
+
surname = name.split()[-1] if name.split() else ""
|
|
200
|
+
surname = unicodedata.normalize("NFKD", surname).encode("ascii", "ignore").decode("ascii")
|
|
201
|
+
surname = re.sub(r"[^a-zA-Z]", "", surname).lower()
|
|
202
|
+
if not surname:
|
|
203
|
+
surname = "unknown"
|
|
204
|
+
|
|
205
|
+
# Year
|
|
206
|
+
year_str = str(year) if year else ""
|
|
207
|
+
|
|
208
|
+
# First significant word of title
|
|
209
|
+
first_word = ""
|
|
210
|
+
if title:
|
|
211
|
+
title_ascii = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
|
|
212
|
+
words = re.findall(r"[a-zA-Z]+", title_ascii.lower())
|
|
213
|
+
for w in words:
|
|
214
|
+
if w not in _KEY_STOPWORDS and len(w) > 2:
|
|
215
|
+
first_word = w
|
|
216
|
+
break
|
|
217
|
+
if not first_word and words:
|
|
218
|
+
first_word = words[0]
|
|
219
|
+
|
|
220
|
+
return f"{surname}{year_str}{first_word}"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------- Server instance ----------
|
|
224
|
+
|
|
225
|
+
server = Server("bibliography")
|
|
226
|
+
log("Server initialized")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Markdown formatting functions for OpenAlex API results."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _get_authors_str(work: dict[str, Any], max_authors: int = 3) -> str:
|
|
7
|
+
"""Extract author names from a work, truncating if needed."""
|
|
8
|
+
authorships = work.get("authorships", [])
|
|
9
|
+
names = [a["author"]["display_name"] for a in authorships if a.get("author")]
|
|
10
|
+
if len(names) > max_authors:
|
|
11
|
+
return ", ".join(names[:max_authors]) + " et al."
|
|
12
|
+
return ", ".join(names) if names else "Unknown"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_journal(work: dict[str, Any]) -> str:
|
|
16
|
+
"""Extract journal/source name from a work."""
|
|
17
|
+
loc = work.get("primary_location") or {}
|
|
18
|
+
source = loc.get("source") or {}
|
|
19
|
+
return source.get("display_name", "")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _clean_doi(doi: str | None) -> str:
|
|
23
|
+
"""Strip https://doi.org/ prefix from DOI."""
|
|
24
|
+
if not doi:
|
|
25
|
+
return ""
|
|
26
|
+
return doi.replace("https://doi.org/", "")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_works_table(works: list[dict[str, Any]], title: str = "Results") -> str:
|
|
30
|
+
"""Format a list of works as a markdown table."""
|
|
31
|
+
if not works:
|
|
32
|
+
return f"## {title}\n\nNo results found."
|
|
33
|
+
|
|
34
|
+
lines = [
|
|
35
|
+
f"## {title}\n",
|
|
36
|
+
f"**{len(works)} works**\n",
|
|
37
|
+
"| # | Title | Authors | Year | Journal | Cites | DOI |",
|
|
38
|
+
"|---|-------|---------|------|---------|-------|-----|",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
for i, w in enumerate(works, 1):
|
|
42
|
+
title_text = (w.get("title") or "Untitled")[:80]
|
|
43
|
+
authors = _get_authors_str(w)
|
|
44
|
+
year = w.get("publication_year", "")
|
|
45
|
+
journal = _get_journal(w)[:30]
|
|
46
|
+
cites = w.get("cited_by_count", 0)
|
|
47
|
+
doi = _clean_doi(w.get("doi"))
|
|
48
|
+
lines.append(f"| {i} | {title_text} | {authors} | {year} | {journal} | {cites} | {doi} |")
|
|
49
|
+
|
|
50
|
+
return "\n".join(lines)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def format_author_profile(analysis: dict[str, Any]) -> str:
|
|
54
|
+
"""Format research output analysis as markdown."""
|
|
55
|
+
if "error" in analysis:
|
|
56
|
+
return f"**Error:** {analysis['error']}"
|
|
57
|
+
|
|
58
|
+
lines = [
|
|
59
|
+
f"## {analysis['entity_name']}",
|
|
60
|
+
"",
|
|
61
|
+
f"- **Total works:** {analysis['total_works']}",
|
|
62
|
+
f"- **Open access:** {analysis['open_access_works']} ({analysis['open_access_percentage']}%)",
|
|
63
|
+
"",
|
|
64
|
+
"### Publications by Year",
|
|
65
|
+
"",
|
|
66
|
+
"| Year | Count |",
|
|
67
|
+
"|------|-------|",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
for entry in sorted(analysis.get("publications_by_year", []),
|
|
71
|
+
key=lambda x: x.get("key", ""), reverse=True):
|
|
72
|
+
lines.append(f"| {entry.get('key', '')} | {entry.get('count', 0)} |")
|
|
73
|
+
|
|
74
|
+
topics = analysis.get("top_topics", [])
|
|
75
|
+
if topics:
|
|
76
|
+
lines.extend(["", "### Top Topics", "", "| Topic | Count |", "|-------|-------|"])
|
|
77
|
+
for t in topics[:10]:
|
|
78
|
+
name = t.get("key_display_name", t.get("key", ""))
|
|
79
|
+
lines.append(f"| {name} | {t.get('count', 0)} |")
|
|
80
|
+
|
|
81
|
+
return "\n".join(lines)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def format_trends(trends: list[dict[str, Any]], search_term: str = "") -> str:
|
|
85
|
+
"""Format publication trends as markdown."""
|
|
86
|
+
if not trends:
|
|
87
|
+
return "No trend data found."
|
|
88
|
+
|
|
89
|
+
header = f"## Publication Trends"
|
|
90
|
+
if search_term:
|
|
91
|
+
header += f": {search_term}"
|
|
92
|
+
|
|
93
|
+
sorted_trends = sorted(trends, key=lambda x: x.get("key", ""), reverse=True)
|
|
94
|
+
|
|
95
|
+
lines = [
|
|
96
|
+
header,
|
|
97
|
+
"",
|
|
98
|
+
"| Year | Publications |",
|
|
99
|
+
"|------|-------------|",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
for entry in sorted_trends[:20]:
|
|
103
|
+
lines.append(f"| {entry.get('key', '')} | {entry.get('count', 0)} |")
|
|
104
|
+
|
|
105
|
+
return "\n".join(lines)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_work_detail(work: dict[str, Any]) -> str:
|
|
109
|
+
"""Format a single work with full metadata."""
|
|
110
|
+
title = work.get("title") or "Untitled"
|
|
111
|
+
authors = _get_authors_str(work, max_authors=10)
|
|
112
|
+
year = work.get("publication_year", "")
|
|
113
|
+
journal = _get_journal(work)
|
|
114
|
+
doi = _clean_doi(work.get("doi"))
|
|
115
|
+
cites = work.get("cited_by_count", 0)
|
|
116
|
+
oa = work.get("open_access", {})
|
|
117
|
+
oa_status = oa.get("oa_status", "unknown")
|
|
118
|
+
oa_url = oa.get("oa_url", "")
|
|
119
|
+
|
|
120
|
+
# Reconstruct abstract from inverted index
|
|
121
|
+
abstract = ""
|
|
122
|
+
inv_index = work.get("abstract_inverted_index")
|
|
123
|
+
if inv_index:
|
|
124
|
+
word_positions: list[tuple[int, str]] = []
|
|
125
|
+
for word, positions in inv_index.items():
|
|
126
|
+
for pos in positions:
|
|
127
|
+
word_positions.append((pos, word))
|
|
128
|
+
word_positions.sort()
|
|
129
|
+
abstract = " ".join(w for _, w in word_positions)
|
|
130
|
+
|
|
131
|
+
lines = [
|
|
132
|
+
f"## {title}",
|
|
133
|
+
"",
|
|
134
|
+
f"**Authors:** {authors}",
|
|
135
|
+
f"**Year:** {year}",
|
|
136
|
+
f"**Journal:** {journal}",
|
|
137
|
+
f"**DOI:** {doi}",
|
|
138
|
+
f"**Citations:** {cites}",
|
|
139
|
+
f"**Open Access:** {oa_status}",
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
if oa_url:
|
|
143
|
+
lines.append(f"**OA URL:** {oa_url}")
|
|
144
|
+
|
|
145
|
+
work_type = work.get("type", "")
|
|
146
|
+
if work_type:
|
|
147
|
+
lines.append(f"**Type:** {work_type}")
|
|
148
|
+
|
|
149
|
+
if abstract:
|
|
150
|
+
lines.extend(["", "### Abstract", "", abstract])
|
|
151
|
+
|
|
152
|
+
# Concepts/topics
|
|
153
|
+
topics = work.get("topics", [])
|
|
154
|
+
if topics:
|
|
155
|
+
topic_names = [t.get("display_name", "") for t in topics[:5]]
|
|
156
|
+
lines.extend(["", f"**Topics:** {', '.join(topic_names)}"])
|
|
157
|
+
|
|
158
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Session Log: 2026-03-13 21:00
|
|
2
|
+
|
|
3
|
+
## Project: .mcp-server-bibliography
|
|
4
|
+
|
|
5
|
+
> Also logged: Task Management (skills updates) — `../../log/2026-03-13-2100.md`
|
|
6
|
+
|
|
7
|
+
## What We Did
|
|
8
|
+
- Added Semantic Scholar as 4th source adapter (created in prior session, tested and confirmed working)
|
|
9
|
+
- Rewrote S2 adapter to use `/paper/search/bulk` (recommended by S2) and Recommendations API for `find_similar_works`
|
|
10
|
+
- Added 4 new S2 Graph API methods: `get_paper_citations`, `get_paper_references`, `search_author` + `get_author_papers`, `get_paper_detail`
|
|
11
|
+
- Extended Paper model with `tldr`, `bibtex`, `open_access_url` fields
|
|
12
|
+
- Exposed 4 new MCP tools: `scholarly_citations`, `scholarly_references`, `scholarly_paper_detail`, `scholarly_author_papers`
|
|
13
|
+
- Server now has 17 tools total (7 OpenAlex + 10 scholarly) across 4 sources
|
|
14
|
+
- Fixed S2 field scoping: base fields for bulk endpoints, extended fields (tldr, citationStyles, isOpenAccess, openAccessPdf) only for single paper lookups
|
|
15
|
+
- Fixed citations/references endpoints to use `citingPaper.` / `citedPaper.` field prefixes
|
|
16
|
+
- All 6 test categories passed: bulk search, DOI verify, forward citations, backward references, author search + papers, paper detail with TLDR/BibTeX, recommendations
|
|
17
|
+
|
|
18
|
+
## Key Decisions
|
|
19
|
+
- S2 Datasets API (bulk download) is NOT suitable for live MCP queries — only Graph + Recommendations are used
|
|
20
|
+
- Extended fields (`tldr`, `citationStyles`) only available on single paper lookup, not bulk/citations/references endpoints — used separate field constants `S2_FIELDS` vs `S2_DETAIL_FIELDS`
|
|
21
|
+
- the user applying for S2 API key for higher rate limits (drafted application answers)
|
|
22
|
+
|
|
23
|
+
## Problems/Blockers
|
|
24
|
+
- First attempt at extended fields caused 400 errors on bulk search, citations, and references endpoints — resolved by splitting field constants
|
|
25
|
+
- Citations/references endpoints need prefixed nested fields (`citingPaper.title`, `citedPaper.title`) — different from other endpoints
|
|
26
|
+
|
|
27
|
+
## Next Steps
|
|
28
|
+
- [ ] Add S2_API_KEY to `research/scout/.env` once the user receives it
|
|
29
|
+
- [ ] Consider adding S2 as a source in Scout CLI itself (currently only OpenAlex/Scopus/WoS)
|
|
30
|
+
|
|
31
|
+
## Files Changed
|
|
32
|
+
- `sources/models.py` — added `tldr`, `bibtex`, `open_access_url` fields to Paper dataclass
|
|
33
|
+
- `sources/semantic_scholar_source.py` — added `S2_DETAIL_FIELDS`, 5 new methods, enriched `_to_paper` with tldr/bibtex/oa_url
|
|
34
|
+
- `server.py` — added 4 new tool definitions and handlers
|
|
35
|
+
- `run.sh` — S2_API_KEY added to env var loading (prior session)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "biblio-mcp"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Multi-source scholarly search MCP server: OpenAlex + Scopus + Web of Science"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"mcp>=1.0.0",
|
|
8
|
+
"requests",
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"biblio-sources @ git+https://github.com/user/biblio-sources.git@9cff529",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.uv.sources]
|
|
14
|
+
biblio-sources = { path = "../biblio-sources", editable = true }
|
|
15
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Wrapper to launch the bibliography MCP server with API keys from Scout's .env
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
ENV_FILE="$(dirname "$0")/../../research/scout/.env"
|
|
6
|
+
|
|
7
|
+
if [[ -f "$ENV_FILE" ]]; then
|
|
8
|
+
while IFS='=' read -r key value; do
|
|
9
|
+
# Skip comments and blank lines
|
|
10
|
+
[[ "$key" =~ ^#.*$ || -z "$key" ]] && continue
|
|
11
|
+
# Only export the keys the server needs
|
|
12
|
+
case "$key" in
|
|
13
|
+
WOS_API_KEY|WOS_API_TIER|SCOPUS_API_KEY|SCOPUS_INST_TOKEN|S2_API_KEY|ORCID_CLIENT_ID|ORCID_CLIENT_SECRET|CORE_API_KEY|ALTMETRIC_API_KEY|ALTMETRIC_API_PASSWORD)
|
|
14
|
+
export "$key"="$value"
|
|
15
|
+
;;
|
|
16
|
+
esac
|
|
17
|
+
done < "$ENV_FILE"
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
exec uv run --directory "$(dirname "$0")" python server.py
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Markdown formatters for multi-source scholarly results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from biblio_sources.models import Paper
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_papers_table(papers: list[Paper], title: str = "Results") -> str:
|
|
9
|
+
"""Format a list of Papers as a markdown table."""
|
|
10
|
+
if not papers:
|
|
11
|
+
return f"## {title}\n\nNo results found."
|
|
12
|
+
|
|
13
|
+
lines = [f"## {title}\n"]
|
|
14
|
+
lines.append("| # | Title | Authors | Year | Cited | Source | DOI |")
|
|
15
|
+
lines.append("|---|-------|---------|------|-------|--------|-----|")
|
|
16
|
+
|
|
17
|
+
for i, p in enumerate(papers, 1):
|
|
18
|
+
authors = ", ".join(p.authors[:3])
|
|
19
|
+
if len(p.authors) > 3:
|
|
20
|
+
authors += " et al."
|
|
21
|
+
title_short = p.title[:80] + "..." if len(p.title) > 80 else p.title
|
|
22
|
+
doi_link = f"[link]({p.doi})" if p.doi else "—"
|
|
23
|
+
source = p.source_name or "—"
|
|
24
|
+
if len(source) > 30:
|
|
25
|
+
source = source[:27] + "..."
|
|
26
|
+
|
|
27
|
+
lines.append(
|
|
28
|
+
f"| {i} | {title_short} | {authors} | {p.publication_year} | "
|
|
29
|
+
f"{p.cited_by_count:,} | {source} | {doi_link} |"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return "\n".join(lines)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_verification_table(results: dict[str, Paper | None]) -> str:
|
|
36
|
+
"""Format DOI verification results as a markdown table."""
|
|
37
|
+
lines = ["## DOI Verification Results\n"]
|
|
38
|
+
lines.append("| # | DOI | Title | Year | Cited By | Verified By | Status |")
|
|
39
|
+
lines.append("|---|-----|-------|------|----------|-------------|--------|")
|
|
40
|
+
|
|
41
|
+
verified_count = 0
|
|
42
|
+
single_count = 0
|
|
43
|
+
not_found_count = 0
|
|
44
|
+
|
|
45
|
+
for i, (doi, paper) in enumerate(results.items(), 1):
|
|
46
|
+
if paper is None:
|
|
47
|
+
status = "NOT FOUND"
|
|
48
|
+
not_found_count += 1
|
|
49
|
+
lines.append(f"| {i} | `{doi}` | — | — | — | — | ❌ {status} |")
|
|
50
|
+
else:
|
|
51
|
+
sources = ", ".join(paper.verified_by)
|
|
52
|
+
if len(paper.verified_by) >= 2:
|
|
53
|
+
status = "VERIFIED"
|
|
54
|
+
verified_count += 1
|
|
55
|
+
else:
|
|
56
|
+
status = "SINGLE SOURCE"
|
|
57
|
+
single_count += 1
|
|
58
|
+
title_short = paper.title[:60] + "..." if len(paper.title) > 60 else paper.title
|
|
59
|
+
lines.append(
|
|
60
|
+
f"| {i} | `{doi}` | {title_short} | {paper.publication_year} | "
|
|
61
|
+
f"{paper.cited_by_count:,} | {sources} | "
|
|
62
|
+
f"{'✅' if status == 'VERIFIED' else '⚠️'} {status} |"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
lines.append("")
|
|
66
|
+
lines.append(f"**Summary:** {verified_count} verified (2+ sources), "
|
|
67
|
+
f"{single_count} single-source, {not_found_count} not found")
|
|
68
|
+
|
|
69
|
+
return "\n".join(lines)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def format_source_status(sources: list[dict]) -> str:
|
|
73
|
+
"""Format source status as a markdown table."""
|
|
74
|
+
lines = ["## Scholarly Source Status\n"]
|
|
75
|
+
lines.append("| Source | Status | Key |")
|
|
76
|
+
lines.append("|--------|--------|-----|")
|
|
77
|
+
|
|
78
|
+
for s in sources:
|
|
79
|
+
status = "✅ Active" if s["active"] else "❌ Not configured"
|
|
80
|
+
key = s.get("key", "—")
|
|
81
|
+
lines.append(f"| {s['name']} | {status} | `{key}` |")
|
|
82
|
+
|
|
83
|
+
return "\n".join(lines)
|