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 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
- ## Install
13
+ ![Demo: daily papers, keyword search, and BibTeX citation over MCP](assets/demo.gif)
12
14
 
13
- Clone it, then point your MCP client (Claude Desktop, Claude Code, or any MCP host) at
14
- `server.js`:
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
- ```bash
17
- git clone https://github.com/QEbellavita/hf-papers-mcp
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": "node",
25
- "args": ["/absolute/path/to/hf-papers-mcp/server.js"]
26
+ "command": "npx",
27
+ "args": ["-y", "hf-papers-mcp"]
26
28
  }
27
29
  }
28
30
  }
29
31
  ```
30
32
 
31
- Not on npm use the clone above rather than `npx hf-papers-mcp`.
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
- No API key needed for reads. Set `HF_TOKEN` only if you want `link` or `claim`.
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
- Everything runs as a fresh subprocess per call no daemon, no cached state, nothing
89
- persisted beyond the optional `bin/daily.js` run marker. Diagnostics go to stderr, since
90
- stdout is the MCP transport and anything else written there breaks the protocol.
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.0.0",
4
- "description": "MCP server for Hugging Face papers \u2014 search, daily digests, metadata, citations, and indexing",
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
- "hf-papers-mcp": "server.js"
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 \"test/**/*.test.js\"",
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, which shells out to
7
- // scripts/paper_manager.py. Nothing here holds state: every call is a fresh
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(/&lt;/g, '<')
54
+ .replace(/&gt;/g, '>')
55
+ .replace(/&quot;/g, '"')
56
+ .replace(/&#39;|&apos;/g, "'")
57
+ .replace(/&amp;/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 { execFile } = require('child_process');
4
- const path = require('path');
3
+ const hf = require('./hf.js');
5
4
 
6
- const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'paper_manager.py');
7
- // A warm `uv run` already takes ~12.5s; the first (cold) call also resolves the
8
- // PEP 723 dependency set, which can take far longer. The old 15s budget timed
9
- // out on cold starts and silently degraded to a depless python3 — so allow real
10
- // headroom here.
11
- const TIMEOUT_MS = 60_000;
12
-
13
- /**
14
- * Shell out to paper_manager.py and return parsed JSON.
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: async (args) => {
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: async (args) => {
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: async (args) => {
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 (args) => {
139
- return runPaperManager(['citation', '--arxiv-id', args.arxiv_id, '--format', args.format || 'bibtex']);
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: async (args) => {
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: async (args) => {
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
  },