hf-papers-mcp 1.0.0 → 1.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/README.md +29 -19
- package/bin/demo.js +54 -0
- package/package.json +5 -12
- package/server.js +2 -3
- package/src/hf.js +322 -0
- package/src/papers.js +18 -80
- package/scripts/paper_manager.py +0 -1252
package/README.md
CHANGED
|
@@ -2,41 +2,40 @@
|
|
|
2
2
|
<img src="./assets/header.svg" alt="hf-papers-mcp — Hugging Face Papers, inside your assistant" width="100%">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
+
<p align="center"><a href="https://github.com/QEbellavita/hf-papers-mcp/actions/workflows/test.yml"><img src="https://github.com/QEbellavita/hf-papers-mcp/actions/workflows/test.yml/badge.svg" alt="tests"></a></p>
|
|
6
|
+
|
|
5
7
|
An MCP server that lets your assistant read Hugging Face Papers — search them, pull the
|
|
6
8
|
daily curated list, fetch metadata, and generate citations.
|
|
7
9
|
|
|
8
10
|
Ask *"what shipped on speculative decoding this week?"* and get real papers with upvote
|
|
9
11
|
counts and abstracts, instead of whatever was in the model's training data.
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+

|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
`
|
|
15
|
+
*(Recorded with [vhs](https://github.com/charmbracelet/vhs) via `vhs demo.tape` — the
|
|
16
|
+
session runs `bin/demo.js`, a real MCP client against the live API.)*
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
Point your MCP client (Claude Desktop, Claude Code, or any MCP host) at it:
|
|
19
21
|
|
|
20
22
|
```json
|
|
21
23
|
{
|
|
22
24
|
"mcpServers": {
|
|
23
25
|
"hf-papers": {
|
|
24
|
-
"command": "
|
|
25
|
-
"args": ["
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "hf-papers-mcp"]
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
**Requires [uv](https://github.com/astral-sh/uv).** The Python side declares its
|
|
34
|
-
dependencies inline (PEP 723), so `uv run` resolves them on first call — no virtualenv to
|
|
35
|
-
create or `pip install` to remember. Without uv it falls back to bare `python3`, which
|
|
36
|
-
lacks `huggingface_hub` and will return degraded results rather than pretending
|
|
37
|
-
everything is fine.
|
|
33
|
+
Or from source: `git clone https://github.com/QEbellavita/hf-papers-mcp` and point
|
|
34
|
+
`command: node, args: [/absolute/path/to/hf-papers-mcp/server.js]` at the checkout.
|
|
38
35
|
|
|
39
|
-
|
|
36
|
+
Pure Node (>= 18), one dependency (`@modelcontextprotocol/sdk`), no Python. No API key
|
|
37
|
+
needed — everything is a public read. If `HF_TOKEN` is set (or you're logged in via
|
|
38
|
+
`hf auth login`), it's sent along, which helps with rate limits.
|
|
40
39
|
|
|
41
40
|
## Tools
|
|
42
41
|
|
|
@@ -85,9 +84,20 @@ the JSON stream and silently degrade structured responses into raw strings.
|
|
|
85
84
|
|
|
86
85
|
## Notes
|
|
87
86
|
|
|
88
|
-
|
|
89
|
-
persisted beyond the optional `bin/daily.js` run
|
|
90
|
-
stdout is the MCP transport and anything else
|
|
87
|
+
Every call is a direct HTTPS request to the public Hugging Face / arXiv APIs — no
|
|
88
|
+
daemon, no cached state, nothing persisted beyond the optional `bin/daily.js` run
|
|
89
|
+
marker. Diagnostics go to stderr, since stdout is the MCP transport and anything else
|
|
90
|
+
written there breaks the protocol.
|
|
91
|
+
|
|
92
|
+
Earlier versions shelled out to a Python sidecar (`scripts/paper_manager.py` via `uv`);
|
|
93
|
+
v1.1.0 ported the six exposed operations to native Node, so cold-start latency dropped
|
|
94
|
+
from ~12s to nothing and the uv/Python requirement is gone.
|
|
95
|
+
|
|
96
|
+
## Citing
|
|
97
|
+
|
|
98
|
+
If this server is useful in your research workflow, cite it via the repo's
|
|
99
|
+
[CITATION.cff](CITATION.cff) (GitHub's "Cite this repository" button), or the
|
|
100
|
+
Zenodo DOI once archived.
|
|
91
101
|
|
|
92
102
|
## Licence
|
|
93
103
|
|
package/bin/demo.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Minimal example MCP client: spawns the server over stdio, calls three tools,
|
|
5
|
+
// pretty-prints the results. Used to record the README demo GIF, and useful as
|
|
6
|
+
// a smoke test / starting point for writing your own client.
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
|
|
10
|
+
const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
13
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
14
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
15
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
16
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
17
|
+
|
|
18
|
+
async function call(client, name, args) {
|
|
19
|
+
console.log(`\n${cyan('▸')} ${bold(name)} ${dim(JSON.stringify(args))}`);
|
|
20
|
+
const res = await client.callTool({ name, arguments: args });
|
|
21
|
+
return JSON.parse(res.content[0].text);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
(async () => {
|
|
25
|
+
const transport = new StdioClientTransport({
|
|
26
|
+
command: process.execPath,
|
|
27
|
+
args: [path.join(ROOT, 'server.js')],
|
|
28
|
+
cwd: ROOT,
|
|
29
|
+
});
|
|
30
|
+
const client = new Client({ name: 'demo', version: '1.0.0' }, { capabilities: {} });
|
|
31
|
+
await client.connect(transport);
|
|
32
|
+
|
|
33
|
+
const { tools } = await client.listTools();
|
|
34
|
+
console.log(`${bold('hf-papers-mcp')} ${dim('—')} ${tools.length} tools: ${dim(tools.map((t) => t.name.replace('hf_papers_', '')).join(', '))}`);
|
|
35
|
+
|
|
36
|
+
const daily = await call(client, 'hf_papers_daily', { limit: 3 });
|
|
37
|
+
for (const p of daily) {
|
|
38
|
+
console.log(` ${yellow(`▲ ${String(p.upvotes).padStart(3)}`)} ${p.title}`);
|
|
39
|
+
console.log(` ${dim(p.url)}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const found = await call(client, 'hf_papers_search', { query: 'speculative decoding', limit: 3 });
|
|
43
|
+
for (const p of found) {
|
|
44
|
+
console.log(` ${yellow(`▲ ${String(p.upvotes).padStart(3)}`)} ${p.title}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const cite = await call(client, 'hf_papers_citation', { arxiv_id: '1706.03762', format: 'bibtex' });
|
|
48
|
+
console.log(cite.result.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
49
|
+
|
|
50
|
+
await client.close();
|
|
51
|
+
})().catch((err) => {
|
|
52
|
+
console.error('demo failed:', err.message);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
package/package.json
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hf-papers-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "MCP server for Hugging Face papers
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "MCP server for Hugging Face papers — search, daily digests, metadata, citations, and indexing. Pure Node, no Python required.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"main": "server.js",
|
|
8
|
-
"bin": {
|
|
9
|
-
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"server.js",
|
|
13
|
-
"src/",
|
|
14
|
-
"scripts/",
|
|
15
|
-
"bin/"
|
|
16
|
-
],
|
|
8
|
+
"bin": { "hf-papers-mcp": "server.js" },
|
|
9
|
+
"files": ["server.js", "src/", "bin/"],
|
|
17
10
|
"scripts": {
|
|
18
11
|
"start": "node server.js",
|
|
19
|
-
"test": "node --test
|
|
12
|
+
"test": "node --test",
|
|
20
13
|
"daily": "node bin/daily.js",
|
|
21
14
|
"prepublishOnly": "npm test"
|
|
22
15
|
},
|
package/server.js
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
// Standalone MCP server exposing the Hugging Face papers tools over stdio.
|
|
5
5
|
//
|
|
6
|
-
// The tool definitions live in src/papers.js
|
|
7
|
-
//
|
|
8
|
-
// subprocess against the public Hugging Face API.
|
|
6
|
+
// The tool definitions live in src/papers.js; src/hf.js talks to the public
|
|
7
|
+
// Hugging Face / arXiv APIs directly over fetch. Nothing here holds state.
|
|
9
8
|
|
|
10
9
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
11
10
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
package/src/hf.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Native port of scripts/paper_manager.py — the six operations the MCP server
|
|
4
|
+
// exposes, reimplemented on global fetch (node >= 18) so the server has no
|
|
5
|
+
// Python/uv dependency and no per-call subprocess.
|
|
6
|
+
//
|
|
7
|
+
// Contract notes preserved from the Python side:
|
|
8
|
+
// - daily() with a bad date returns [ { error } ] (an array), matching the CLI.
|
|
9
|
+
// - check() with a bad arXiv id returns { exists: false, error }.
|
|
10
|
+
// - citation() returns a raw string; the tool handler wraps it as { result }.
|
|
11
|
+
// - Text pulled from arXiv/HF is sanitized before it can reach Markdown/YAML.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
|
|
17
|
+
const HF_API_BASE = 'https://huggingface.co/api';
|
|
18
|
+
const TIMEOUT_MS = 15_000;
|
|
19
|
+
|
|
20
|
+
const ARXIV_ID_MODERN = /^\d{4}\.\d{4,5}(v\d+)?$/;
|
|
21
|
+
const ARXIV_ID_LEGACY = /^[a-zA-Z-]+\/\d{7}(v\d+)?$/;
|
|
22
|
+
|
|
23
|
+
// ------------------------------------------------------------------
|
|
24
|
+
// Pure helpers (unit-tested offline)
|
|
25
|
+
// ------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function cleanArxivId(raw) {
|
|
28
|
+
let id = String(raw ?? '').trim();
|
|
29
|
+
id = id.replace(/^arxiv:/i, '');
|
|
30
|
+
id = id.replace(/^https?:\/\/arxiv\.org\/(abs|pdf)\//, '');
|
|
31
|
+
id = id.replace(/\.pdf$/, '');
|
|
32
|
+
if (!ARXIV_ID_MODERN.test(id) && !ARXIV_ID_LEGACY.test(id)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Invalid arXiv ID: ${JSON.stringify(String(raw))}. ` +
|
|
35
|
+
'Expected format: YYMM.NNNNN[vN] or category/YYMMNNN[vN]'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return id;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sanitizeText(text) {
|
|
42
|
+
let out = String(text);
|
|
43
|
+
out = out.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
|
44
|
+
out = out.replace(/[^\S\n]+/g, ' ');
|
|
45
|
+
out = out.replace(/\n{3,}/g, '\n\n');
|
|
46
|
+
out = out.replace(/```/g, '\\`\\`\\`');
|
|
47
|
+
out = out.replace(/^---/gm, '\\---');
|
|
48
|
+
return out.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decodeXmlEntities(text) {
|
|
52
|
+
return String(text)
|
|
53
|
+
.replace(/</g, '<')
|
|
54
|
+
.replace(/>/g, '>')
|
|
55
|
+
.replace(/"/g, '"')
|
|
56
|
+
.replace(/'|'/g, "'")
|
|
57
|
+
.replace(/&/g, '&');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// arXiv's Atom feed is flat and small; targeted regexes match the Python port's
|
|
61
|
+
// approach and avoid an XML-parser dependency.
|
|
62
|
+
function parseArxivAtom(xml, arxivId) {
|
|
63
|
+
// First <title> is the feed's own title — the paper title is the second.
|
|
64
|
+
const titles = [...xml.matchAll(/<title>([\s\S]*?)<\/title>/g)].map((m) => m[1]);
|
|
65
|
+
const authors = [...xml.matchAll(/<name>([\s\S]*?)<\/name>/g)].map((m) => m[1]);
|
|
66
|
+
const summary = /<summary>([\s\S]*?)<\/summary>/.exec(xml);
|
|
67
|
+
|
|
68
|
+
const rawTitle = titles.length > 1 ? titles[1].trim() : null;
|
|
69
|
+
const rawAbstract = summary ? summary[1].trim() : null;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
arxiv_id: arxivId,
|
|
73
|
+
title: rawTitle ? sanitizeText(decodeXmlEntities(rawTitle)) : null,
|
|
74
|
+
authors: authors.map((a) => sanitizeText(decodeXmlEntities(a))),
|
|
75
|
+
abstract: rawAbstract ? sanitizeText(decodeXmlEntities(rawAbstract)) : null,
|
|
76
|
+
arxiv_url: `https://arxiv.org/abs/${arxivId}`,
|
|
77
|
+
pdf_url: `https://arxiv.org/pdf/${arxivId}.pdf`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function arxivYear(arxivId) {
|
|
82
|
+
// Modern ids embed YYMM; legacy ids (cs/0701001) embed YY after the slash.
|
|
83
|
+
const numeric = arxivId.includes('/') ? arxivId.split('/')[1] : arxivId;
|
|
84
|
+
const yy = parseInt(numeric.slice(0, 2), 10);
|
|
85
|
+
if (Number.isNaN(yy)) return null;
|
|
86
|
+
return yy < 50 ? `20${String(yy).padStart(2, '0')}` : `19${yy}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatCitation(info, fmt = 'bibtex') {
|
|
90
|
+
const authors = info.authors && info.authors.length ? info.authors : ['Unknown'];
|
|
91
|
+
const title = info.title || 'Untitled';
|
|
92
|
+
const arxivId = info.arxiv_id;
|
|
93
|
+
const year = arxivYear(arxivId) || 'n.d.';
|
|
94
|
+
|
|
95
|
+
if (fmt === 'bibtex') {
|
|
96
|
+
const key = `arxiv${arxivId.replace(/[./]/g, '_')}`;
|
|
97
|
+
const escape = (s) => s.replace(/\{/g, '\\{').replace(/\}/g, '\\}');
|
|
98
|
+
return (
|
|
99
|
+
`@article{${key},\n` +
|
|
100
|
+
` title={${escape(title)}},\n` +
|
|
101
|
+
` author={${escape(authors.join(' and '))}},\n` +
|
|
102
|
+
` journal={arXiv preprint arXiv:${arxivId}},\n` +
|
|
103
|
+
` year={${year}}\n` +
|
|
104
|
+
`}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (fmt === 'apa') {
|
|
109
|
+
let authorStr;
|
|
110
|
+
if (authors.length > 7) {
|
|
111
|
+
authorStr = `${authors.slice(0, 6).join(', ')}, ... ${authors[authors.length - 1]}`;
|
|
112
|
+
} else if (authors.length > 1) {
|
|
113
|
+
authorStr = `${authors.slice(0, -1).join(', ')}, & ${authors[authors.length - 1]}`;
|
|
114
|
+
} else {
|
|
115
|
+
authorStr = authors[0];
|
|
116
|
+
}
|
|
117
|
+
return `${authorStr} (${year}). ${title}. arXiv preprint arXiv:${arxivId}.`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (fmt === 'mla') {
|
|
121
|
+
let authorStr;
|
|
122
|
+
if (authors.length > 2) authorStr = `${authors[0]}, et al.`;
|
|
123
|
+
else if (authors.length === 2) authorStr = `${authors[0]}, and ${authors[1]}`;
|
|
124
|
+
else authorStr = authors[0];
|
|
125
|
+
// The Python port appended "." unconditionally, yielding "et al.." — don't.
|
|
126
|
+
const sep = authorStr.endsWith('.') ? '' : '.';
|
|
127
|
+
return `${authorStr}${sep} "${title}." arXiv preprint arXiv:${arxivId} (${year}).`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return `Format '${fmt}' not supported. Use bibtex, apa, or mla.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function mapSearchEntry(entry) {
|
|
134
|
+
const paper = entry.paper || entry;
|
|
135
|
+
return {
|
|
136
|
+
arxiv_id: paper.id,
|
|
137
|
+
title: paper.title,
|
|
138
|
+
upvotes: paper.upvotes || 0,
|
|
139
|
+
summary: String(paper.ai_summary || paper.summary || '').slice(0, 200),
|
|
140
|
+
authors: (paper.authors || []).map((a) => a.name).slice(0, 5),
|
|
141
|
+
url: `https://huggingface.co/papers/${paper.id}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mapDailyEntry(entry) {
|
|
146
|
+
const paper = entry.paper || entry;
|
|
147
|
+
return {
|
|
148
|
+
arxiv_id: paper.id,
|
|
149
|
+
title: paper.title || entry.title,
|
|
150
|
+
upvotes: paper.upvotes || 0,
|
|
151
|
+
summary: String(paper.ai_summary || paper.summary || '').slice(0, 200),
|
|
152
|
+
num_comments: entry.numComments || 0,
|
|
153
|
+
submitted_by: (entry.submittedBy || {}).name ?? null,
|
|
154
|
+
url: `https://huggingface.co/papers/${paper.id}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ------------------------------------------------------------------
|
|
159
|
+
// Auth + fetch
|
|
160
|
+
// ------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
function readToken() {
|
|
163
|
+
if (process.env.HF_TOKEN) return process.env.HF_TOKEN;
|
|
164
|
+
// Same fallback huggingface_hub's get_token() uses.
|
|
165
|
+
const candidates = [
|
|
166
|
+
process.env.HF_TOKEN_PATH,
|
|
167
|
+
path.join(process.env.HF_HOME || path.join(os.homedir(), '.cache', 'huggingface'), 'token'),
|
|
168
|
+
].filter(Boolean);
|
|
169
|
+
for (const file of candidates) {
|
|
170
|
+
try {
|
|
171
|
+
const token = fs.readFileSync(file, 'utf8').trim();
|
|
172
|
+
if (token) return token;
|
|
173
|
+
} catch { /* not logged in — reads work anonymously */ }
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function fetchJson(url, params) {
|
|
179
|
+
const target = new URL(url);
|
|
180
|
+
for (const [k, v] of Object.entries(params || {})) {
|
|
181
|
+
if (v !== undefined && v !== null) target.searchParams.set(k, String(v));
|
|
182
|
+
}
|
|
183
|
+
const headers = {};
|
|
184
|
+
const token = readToken();
|
|
185
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
186
|
+
const resp = await fetch(target, { headers, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
187
|
+
return resp;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ------------------------------------------------------------------
|
|
191
|
+
// Operations (one per MCP tool)
|
|
192
|
+
// ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
async function getPaper(arxivId) {
|
|
195
|
+
const resp = await fetchJson(`${HF_API_BASE}/papers/${arxivId}`);
|
|
196
|
+
if (resp.status === 404) {
|
|
197
|
+
return { error: 'not_found', message: `Paper ${arxivId} not indexed on HF` };
|
|
198
|
+
}
|
|
199
|
+
if (!resp.ok) throw new Error(`HF API ${resp.status} for papers/${arxivId}`);
|
|
200
|
+
return resp.json();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function searchPapers(query, limit = 20) {
|
|
204
|
+
const resp = await fetchJson(`${HF_API_BASE}/papers/search`, { q: query });
|
|
205
|
+
if (!resp.ok) throw new Error(`HF API ${resp.status} for papers/search`);
|
|
206
|
+
const papers = await resp.json();
|
|
207
|
+
return papers.slice(0, limit).map(mapSearchEntry);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function dailyPapers(dateStr, limit = 30) {
|
|
211
|
+
const params = {};
|
|
212
|
+
if (dateStr) {
|
|
213
|
+
// Round-trip check: Date rolls calendar-invalid dates (2026-02-30) over
|
|
214
|
+
// instead of rejecting them, unlike Python's strptime.
|
|
215
|
+
const parsed = new Date(`${dateStr}T00:00:00Z`);
|
|
216
|
+
if (
|
|
217
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(dateStr) ||
|
|
218
|
+
Number.isNaN(parsed.getTime()) ||
|
|
219
|
+
parsed.toISOString().slice(0, 10) !== dateStr
|
|
220
|
+
) {
|
|
221
|
+
return [{ error: `Invalid date format: ${dateStr}. Use YYYY-MM-DD` }];
|
|
222
|
+
}
|
|
223
|
+
params.date = dateStr;
|
|
224
|
+
}
|
|
225
|
+
const resp = await fetchJson(`${HF_API_BASE}/daily_papers`, params);
|
|
226
|
+
if (!resp.ok) throw new Error(`HF API ${resp.status} for daily_papers`);
|
|
227
|
+
const papers = await resp.json();
|
|
228
|
+
return papers.slice(0, limit).map(mapDailyEntry);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function checkPaper(rawId) {
|
|
232
|
+
let arxivId;
|
|
233
|
+
try {
|
|
234
|
+
arxivId = cleanArxivId(rawId);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return { exists: false, error: err.message };
|
|
237
|
+
}
|
|
238
|
+
const data = await getPaper(arxivId);
|
|
239
|
+
if (data.error) {
|
|
240
|
+
return {
|
|
241
|
+
exists: false,
|
|
242
|
+
arxiv_id: arxivId,
|
|
243
|
+
index_url: `https://huggingface.co/papers/${arxivId}`,
|
|
244
|
+
message: 'Visit the URL to index this paper',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
exists: true,
|
|
249
|
+
arxiv_id: arxivId,
|
|
250
|
+
title: data.title,
|
|
251
|
+
upvotes: data.upvotes || 0,
|
|
252
|
+
authors: (data.authors || []).map((a) => a.name),
|
|
253
|
+
url: `https://huggingface.co/papers/${arxivId}`,
|
|
254
|
+
arxiv_url: `https://arxiv.org/abs/${arxivId}`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function indexPaper(rawId) {
|
|
259
|
+
let arxivId;
|
|
260
|
+
try {
|
|
261
|
+
arxivId = cleanArxivId(rawId);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return { status: 'error', message: err.message };
|
|
264
|
+
}
|
|
265
|
+
const paperUrl = `https://huggingface.co/papers/${arxivId}`;
|
|
266
|
+
try {
|
|
267
|
+
// GET the paper page — HF indexes on first visit.
|
|
268
|
+
const resp = await fetch(paperUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
269
|
+
if (resp.ok) return { status: 'indexed', url: paperUrl, arxiv_id: arxivId };
|
|
270
|
+
return { status: 'not_indexed', url: paperUrl, action: 'visit_url' };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
return { status: 'error', message: err.message };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function getArxivInfo(rawId) {
|
|
277
|
+
let arxivId;
|
|
278
|
+
try {
|
|
279
|
+
arxivId = cleanArxivId(rawId);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
return { error: err.message };
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const resp = await fetch(
|
|
285
|
+
`https://export.arxiv.org/api/query?id_list=${encodeURIComponent(arxivId)}`,
|
|
286
|
+
{ signal: AbortSignal.timeout(TIMEOUT_MS) }
|
|
287
|
+
);
|
|
288
|
+
if (!resp.ok) throw new Error(`arXiv API ${resp.status}`);
|
|
289
|
+
return parseArxivAtom(await resp.text(), arxivId);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return { error: err.message };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function generateCitation(rawId, fmt = 'bibtex') {
|
|
296
|
+
let arxivId;
|
|
297
|
+
try {
|
|
298
|
+
arxivId = cleanArxivId(rawId);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return `Error: ${err.message}`;
|
|
301
|
+
}
|
|
302
|
+
const info = await getArxivInfo(arxivId);
|
|
303
|
+
if (info.error) return `Error fetching paper info: ${info.error}`;
|
|
304
|
+
return formatCitation(info, fmt);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = {
|
|
308
|
+
// operations
|
|
309
|
+
searchPapers,
|
|
310
|
+
dailyPapers,
|
|
311
|
+
checkPaper,
|
|
312
|
+
indexPaper,
|
|
313
|
+
getArxivInfo,
|
|
314
|
+
generateCitation,
|
|
315
|
+
// pure helpers, exported for tests
|
|
316
|
+
cleanArxivId,
|
|
317
|
+
sanitizeText,
|
|
318
|
+
parseArxivAtom,
|
|
319
|
+
formatCitation,
|
|
320
|
+
mapSearchEntry,
|
|
321
|
+
mapDailyEntry,
|
|
322
|
+
};
|
package/src/papers.js
CHANGED
|
@@ -1,66 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const path = require('path');
|
|
3
|
+
const hf = require('./hf.js');
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* Prefers `uv run` (auto-resolves PEP 723 deps). Falls back to `python3` ONLY
|
|
16
|
-
* when uv is genuinely absent — a uv timeout or runtime failure is surfaced
|
|
17
|
-
* rather than masked, because python3 here lacks huggingface_hub and would
|
|
18
|
-
* return misleading/empty data.
|
|
19
|
-
*
|
|
20
|
-
* IMPORTANT: --json must come BEFORE the subcommand (argparse global flag).
|
|
21
|
-
*/
|
|
22
|
-
function runPaperManager(args) {
|
|
23
|
-
return new Promise((resolve) => {
|
|
24
|
-
const tryRun = (cmd, cmdArgs) => {
|
|
25
|
-
execFile(cmd, cmdArgs, {
|
|
26
|
-
timeout: TIMEOUT_MS,
|
|
27
|
-
maxBuffer: 1024 * 1024,
|
|
28
|
-
}, (err, stdout, stderr) => {
|
|
29
|
-
if (err && cmd === 'uv' && err.code === 'ENOENT') {
|
|
30
|
-
// uv binary not installed — fall back to python3
|
|
31
|
-
tryRun('python3', [SCRIPT, '--json', ...args]);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
if (err) {
|
|
35
|
-
// Surface real failures (timeout, runtime error) instead of degrading.
|
|
36
|
-
const reason = err.killed ? `timed out after ${TIMEOUT_MS}ms` : err.message;
|
|
37
|
-
resolve({ error: reason, stderr: stderr?.trim() || '' });
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
// The python3 path has no huggingface_hub, so anything it does return is
|
|
41
|
-
// partial. Label it rather than let it read as a complete answer.
|
|
42
|
-
const degraded = cmd === 'python3'
|
|
43
|
-
? { degraded: 'uv not installed; ran bare python3 without huggingface_hub — results may be incomplete' }
|
|
44
|
-
: {};
|
|
45
|
-
try {
|
|
46
|
-
const parsed = JSON.parse(stdout);
|
|
47
|
-
// search/daily/index return arrays. Object-spreading an array yields
|
|
48
|
-
// {0:…,1:…}, so the caller receives a map keyed by index instead of a
|
|
49
|
-
// list. Keep arrays intact; only wrap when there is a label to attach.
|
|
50
|
-
if (Array.isArray(parsed)) {
|
|
51
|
-
resolve(degraded.degraded ? { results: parsed, ...degraded } : parsed);
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
resolve({ ...parsed, ...degraded });
|
|
55
|
-
} catch {
|
|
56
|
-
// Output wasn't JSON — return raw text (e.g., citation command)
|
|
57
|
-
resolve({ result: stdout.trim(), ...degraded });
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
};
|
|
61
|
-
// Try uv first (auto-resolves PEP 723 dependencies), then python3
|
|
62
|
-
tryRun('uv', ['run', SCRIPT, '--json', ...args]);
|
|
63
|
-
});
|
|
5
|
+
// Handlers resolve to { error } instead of rejecting, matching the contract of
|
|
6
|
+
// the old Python-subprocess bridge: callers always get JSON, never an MCP-level
|
|
7
|
+
// exception, for expected failures (network down, bad id, API error).
|
|
8
|
+
async function safely(op) {
|
|
9
|
+
try {
|
|
10
|
+
return await op();
|
|
11
|
+
} catch (err) {
|
|
12
|
+
return { error: err.message };
|
|
13
|
+
}
|
|
64
14
|
}
|
|
65
15
|
|
|
66
16
|
module.exports = {
|
|
@@ -84,9 +34,7 @@ module.exports = {
|
|
|
84
34
|
required: ['query'],
|
|
85
35
|
},
|
|
86
36
|
},
|
|
87
|
-
handler:
|
|
88
|
-
return runPaperManager(['search', '--query', args.query, '--limit', String(args.limit || 10)]);
|
|
89
|
-
},
|
|
37
|
+
handler: (args) => safely(() => hf.searchPapers(args.query, args.limit || 10)),
|
|
90
38
|
},
|
|
91
39
|
{
|
|
92
40
|
def: {
|
|
@@ -100,11 +48,7 @@ module.exports = {
|
|
|
100
48
|
},
|
|
101
49
|
},
|
|
102
50
|
},
|
|
103
|
-
handler:
|
|
104
|
-
const cmdArgs = ['daily', '--limit', String(args.limit || 15)];
|
|
105
|
-
if (args.date) cmdArgs.push('--date', args.date);
|
|
106
|
-
return runPaperManager(cmdArgs);
|
|
107
|
-
},
|
|
51
|
+
handler: (args) => safely(() => hf.dailyPapers(args.date, args.limit || 15)),
|
|
108
52
|
},
|
|
109
53
|
{
|
|
110
54
|
def: {
|
|
@@ -118,9 +62,7 @@ module.exports = {
|
|
|
118
62
|
required: ['arxiv_id'],
|
|
119
63
|
},
|
|
120
64
|
},
|
|
121
|
-
handler:
|
|
122
|
-
return runPaperManager(['info', '--arxiv-id', args.arxiv_id]);
|
|
123
|
-
},
|
|
65
|
+
handler: (args) => safely(() => hf.getArxivInfo(args.arxiv_id)),
|
|
124
66
|
},
|
|
125
67
|
{
|
|
126
68
|
def: {
|
|
@@ -135,9 +77,9 @@ module.exports = {
|
|
|
135
77
|
required: ['arxiv_id'],
|
|
136
78
|
},
|
|
137
79
|
},
|
|
138
|
-
handler: async (
|
|
139
|
-
|
|
140
|
-
},
|
|
80
|
+
handler: (args) => safely(async () => ({
|
|
81
|
+
result: await hf.generateCitation(args.arxiv_id, args.format || 'bibtex'),
|
|
82
|
+
})),
|
|
141
83
|
},
|
|
142
84
|
{
|
|
143
85
|
def: {
|
|
@@ -151,9 +93,7 @@ module.exports = {
|
|
|
151
93
|
required: ['arxiv_id'],
|
|
152
94
|
},
|
|
153
95
|
},
|
|
154
|
-
handler:
|
|
155
|
-
return runPaperManager(['check', '--arxiv-id', args.arxiv_id]);
|
|
156
|
-
},
|
|
96
|
+
handler: (args) => safely(() => hf.checkPaper(args.arxiv_id)),
|
|
157
97
|
},
|
|
158
98
|
{
|
|
159
99
|
def: {
|
|
@@ -167,9 +107,7 @@ module.exports = {
|
|
|
167
107
|
required: ['arxiv_id'],
|
|
168
108
|
},
|
|
169
109
|
},
|
|
170
|
-
handler:
|
|
171
|
-
return runPaperManager(['index', '--arxiv-id', args.arxiv_id]);
|
|
172
|
-
},
|
|
110
|
+
handler: (args) => safely(() => hf.indexPaper(args.arxiv_id)),
|
|
173
111
|
},
|
|
174
112
|
];
|
|
175
113
|
},
|