@research-copilot/mcp-scholar 1.0.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/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/mcp-scholar.js +361 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ldm2060
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# @research-copilot/mcp-scholar
|
|
2
|
+
|
|
3
|
+
MCP server for scholarly paper search, aggregating results from multiple academic search backends.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @research-copilot/mcp-scholar
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or via MCP configuration:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"research-scholar": {
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["-y", "@research-copilot/mcp-scholar"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Tools
|
|
25
|
+
|
|
26
|
+
### `scholar_search`
|
|
27
|
+
|
|
28
|
+
Search for academic papers across multiple backends.
|
|
29
|
+
|
|
30
|
+
**Inputs:**
|
|
31
|
+
- `query` (string, required) - Search query
|
|
32
|
+
- `limit` (number, optional) - Max results per backend (default: 10)
|
|
33
|
+
- `backends` (string[], optional) - Backends to query (default: ["arxiv", "dblp"])
|
|
34
|
+
|
|
35
|
+
**Backends:**
|
|
36
|
+
- `arxiv` - arXiv.org (full backend, reliable)
|
|
37
|
+
- `dblp` - DBLP Computer Science Bibliography (full backend, reliable)
|
|
38
|
+
- `scholar` - Google Scholar via SerpAPI (best-effort, requires API key)
|
|
39
|
+
- `arxivsub` - arXiv submission tracker (best-effort)
|
|
40
|
+
|
|
41
|
+
**Returns:** Array of paper objects with fields:
|
|
42
|
+
- `title` (string)
|
|
43
|
+
- `authors` (string[])
|
|
44
|
+
- `year` (number)
|
|
45
|
+
- `venue` (string)
|
|
46
|
+
- `url` (string)
|
|
47
|
+
- `abstract` (string, if available)
|
|
48
|
+
- `citations` (number, if available)
|
|
49
|
+
- `source` (string) - Backend that provided this result
|
|
50
|
+
|
|
51
|
+
### `scholar_metadata`
|
|
52
|
+
|
|
53
|
+
Get detailed metadata for a specific arXiv paper.
|
|
54
|
+
|
|
55
|
+
**Inputs:**
|
|
56
|
+
- `arxiv_id` (string, required) - arXiv ID (e.g., "2401.12345")
|
|
57
|
+
|
|
58
|
+
**Returns:** Paper metadata with full details from arXiv API.
|
|
59
|
+
|
|
60
|
+
### `scholar_bibtex`
|
|
61
|
+
|
|
62
|
+
Get BibTeX citation for a paper from DBLP.
|
|
63
|
+
|
|
64
|
+
**Inputs:**
|
|
65
|
+
- `dblp_key` (string, required) - DBLP key (e.g., "conf/icml/SmithJ23")
|
|
66
|
+
|
|
67
|
+
**Returns:**
|
|
68
|
+
- `bibtex` (string) - BibTeX entry, or empty string if not found
|
|
69
|
+
|
|
70
|
+
## Backend Details
|
|
71
|
+
|
|
72
|
+
**arXiv** (full backend):
|
|
73
|
+
- Atom XML API
|
|
74
|
+
- Reliable, no rate limits for reasonable use
|
|
75
|
+
- Returns: title, authors, year, abstract, arXiv URL
|
|
76
|
+
|
|
77
|
+
**DBLP** (full backend):
|
|
78
|
+
- JSON API + BibTeX fetching
|
|
79
|
+
- Reliable, no API key required
|
|
80
|
+
- Returns: title, authors, year, venue, DBLP URL, BibTeX
|
|
81
|
+
|
|
82
|
+
**Google Scholar** (best-effort):
|
|
83
|
+
- Via SerpAPI (requires `SERP_API_KEY` environment variable)
|
|
84
|
+
- Rate-limited by SerpAPI plan
|
|
85
|
+
- Returns: title, authors, year, citations, URL
|
|
86
|
+
- Errors are logged but don't fail the overall search
|
|
87
|
+
|
|
88
|
+
**arXiv Submission** (best-effort):
|
|
89
|
+
- Tracks recent arXiv submissions
|
|
90
|
+
- May be unstable
|
|
91
|
+
- Errors are logged but don't fail the overall search
|
|
92
|
+
|
|
93
|
+
## Multi-Backend Aggregation
|
|
94
|
+
|
|
95
|
+
The `scholar_search` tool runs queries against all enabled backends in parallel and aggregates results. Per-backend failures are isolated (one backend error doesn't fail the entire search). Each result is tagged with its `source` backend.
|
|
96
|
+
|
|
97
|
+
**Default backends:** `["arxiv", "dblp"]` (reliable, no API keys required)
|
|
98
|
+
|
|
99
|
+
**To use Google Scholar:** Set `SERP_API_KEY` environment variable and include `"scholar"` in backends array.
|
|
100
|
+
|
|
101
|
+
## Architecture
|
|
102
|
+
|
|
103
|
+
- `src/facade.ts` - Multi-backend orchestration with failure isolation
|
|
104
|
+
- `src/backends/arxiv.ts` - arXiv API client (Atom XML)
|
|
105
|
+
- `src/backends/dblp.ts` - DBLP API client (JSON + BibTeX)
|
|
106
|
+
- `src/backends/scholar.ts` - Google Scholar via SerpAPI
|
|
107
|
+
- `src/backends/arxivsub.ts` - arXiv submission tracker
|
|
108
|
+
- `src/server.ts` - MCP server implementation
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/mcp-scholar.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/server.ts
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
// src/backends/arxiv.ts
|
|
11
|
+
import { XMLParser } from "fast-xml-parser";
|
|
12
|
+
var ENDPOINT = "http://export.arxiv.org/api/query";
|
|
13
|
+
var parser = new XMLParser({
|
|
14
|
+
ignoreAttributes: true,
|
|
15
|
+
trimValues: true
|
|
16
|
+
});
|
|
17
|
+
function toArray(value) {
|
|
18
|
+
if (value === void 0 || value === null) return [];
|
|
19
|
+
return Array.isArray(value) ? value : [value];
|
|
20
|
+
}
|
|
21
|
+
function arxivIdFromUrl(idUrl) {
|
|
22
|
+
const match = idUrl.match(/abs\/(.+)$/);
|
|
23
|
+
return match ? match[1] : idUrl;
|
|
24
|
+
}
|
|
25
|
+
async function arxivSearch(query, limit, fetchImpl = fetch) {
|
|
26
|
+
const url = `${ENDPOINT}?search_query=all:${encodeURIComponent(query)}&max_results=${limit}`;
|
|
27
|
+
const res = await fetchImpl(url);
|
|
28
|
+
const xml = await res.text();
|
|
29
|
+
const doc = parser.parse(xml);
|
|
30
|
+
const entries = toArray(doc.feed?.entry);
|
|
31
|
+
return entries.map((entry) => {
|
|
32
|
+
const idUrl = typeof entry.id === "string" ? entry.id : "";
|
|
33
|
+
const authors = toArray(entry.author).map((a) => typeof a?.name === "string" ? a.name : "").filter((n) => n.length > 0);
|
|
34
|
+
const published = typeof entry.published === "string" ? entry.published : void 0;
|
|
35
|
+
const year = published ? Number(published.slice(0, 4)) : void 0;
|
|
36
|
+
const title = typeof entry.title === "string" ? entry.title.trim() : "";
|
|
37
|
+
const abstract = typeof entry.summary === "string" ? entry.summary.trim() : void 0;
|
|
38
|
+
return {
|
|
39
|
+
id: arxivIdFromUrl(idUrl),
|
|
40
|
+
title,
|
|
41
|
+
authors,
|
|
42
|
+
year: Number.isFinite(year) ? year : void 0,
|
|
43
|
+
abstract,
|
|
44
|
+
url: idUrl || void 0,
|
|
45
|
+
source: "arxiv"
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async function arxivMetadata(id, fetchImpl = fetch) {
|
|
50
|
+
const url = `${ENDPOINT}?id_list=${encodeURIComponent(id)}&max_results=1`;
|
|
51
|
+
const res = await fetchImpl(url);
|
|
52
|
+
const xml = await res.text();
|
|
53
|
+
const doc = parser.parse(xml);
|
|
54
|
+
const entries = toArray(doc.feed?.entry);
|
|
55
|
+
if (entries.length === 0) return void 0;
|
|
56
|
+
const entry = entries[0];
|
|
57
|
+
const idUrl = typeof entry.id === "string" ? entry.id : "";
|
|
58
|
+
const authors = toArray(entry.author).map((a) => typeof a?.name === "string" ? a.name : "").filter((n) => n.length > 0);
|
|
59
|
+
const published = typeof entry.published === "string" ? entry.published : void 0;
|
|
60
|
+
const year = published ? Number(published.slice(0, 4)) : void 0;
|
|
61
|
+
return {
|
|
62
|
+
id: arxivIdFromUrl(idUrl),
|
|
63
|
+
title: typeof entry.title === "string" ? entry.title.trim() : "",
|
|
64
|
+
authors,
|
|
65
|
+
year: Number.isFinite(year) ? year : void 0,
|
|
66
|
+
abstract: typeof entry.summary === "string" ? entry.summary.trim() : void 0,
|
|
67
|
+
url: idUrl || void 0,
|
|
68
|
+
source: "arxiv"
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/backends/dblp.ts
|
|
73
|
+
var SEARCH_ENDPOINT = "https://dblp.org/search/publ/api";
|
|
74
|
+
function toArray2(value) {
|
|
75
|
+
if (value === void 0 || value === null) return [];
|
|
76
|
+
return Array.isArray(value) ? value : [value];
|
|
77
|
+
}
|
|
78
|
+
async function dblpSearch(query, limit, fetchImpl = fetch) {
|
|
79
|
+
const url = `${SEARCH_ENDPOINT}?q=${encodeURIComponent(query)}&format=json&h=${limit}`;
|
|
80
|
+
const res = await fetchImpl(url);
|
|
81
|
+
const json = await res.json();
|
|
82
|
+
const hits = toArray2(json.result?.hits?.hit);
|
|
83
|
+
return hits.map((hit) => {
|
|
84
|
+
const info = hit.info ?? {};
|
|
85
|
+
const authors = toArray2(info.authors?.author).map((a) => typeof a?.text === "string" ? a.text : "").filter((n) => n.length > 0);
|
|
86
|
+
const yearNum = info.year !== void 0 ? Number(info.year) : void 0;
|
|
87
|
+
return {
|
|
88
|
+
id: typeof info.key === "string" ? info.key : info.url ?? "",
|
|
89
|
+
title: typeof info.title === "string" ? info.title : "",
|
|
90
|
+
authors,
|
|
91
|
+
year: yearNum !== void 0 && Number.isFinite(yearNum) ? yearNum : void 0,
|
|
92
|
+
url: typeof info.url === "string" ? info.url : void 0,
|
|
93
|
+
source: "dblp"
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async function dblpBibtex(keyOrUrl, fetchImpl = fetch) {
|
|
98
|
+
try {
|
|
99
|
+
const url = bibtexUrl(keyOrUrl);
|
|
100
|
+
const res = await fetchImpl(url);
|
|
101
|
+
if (!res.ok) return "";
|
|
102
|
+
const text = await res.text();
|
|
103
|
+
return text;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
process.stderr.write(
|
|
106
|
+
`mcp-scholar: dblpBibtex failed for "${keyOrUrl}": ${errMsg(err)}
|
|
107
|
+
`
|
|
108
|
+
);
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function bibtexUrl(keyOrUrl) {
|
|
113
|
+
if (/^https?:\/\//.test(keyOrUrl)) {
|
|
114
|
+
return keyOrUrl.endsWith(".bib") ? keyOrUrl : `${keyOrUrl}.bib`;
|
|
115
|
+
}
|
|
116
|
+
return `https://dblp.org/rec/${keyOrUrl}.bib`;
|
|
117
|
+
}
|
|
118
|
+
function errMsg(err) {
|
|
119
|
+
return err instanceof Error ? err.message : String(err);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/backends/scholar.ts
|
|
123
|
+
var ENDPOINT2 = "https://scholar.google.com/scholar";
|
|
124
|
+
async function scholarSearch(query, limit, fetchImpl = fetch) {
|
|
125
|
+
try {
|
|
126
|
+
const url = `${ENDPOINT2}?q=${encodeURIComponent(query)}&hl=en&num=${limit}`;
|
|
127
|
+
const res = await fetchImpl(url, {
|
|
128
|
+
headers: {
|
|
129
|
+
// A desktop UA reduces (does not eliminate) bot blocking.
|
|
130
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
|
|
131
|
+
"accept-language": "en-US,en;q=0.9"
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) return [];
|
|
135
|
+
const html = await res.text();
|
|
136
|
+
if (/captcha|unusual traffic|not a robot/i.test(html)) return [];
|
|
137
|
+
return parseScholarHtml(html, limit);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
process.stderr.write(
|
|
140
|
+
`mcp-scholar: scholar backend degraded to []: ${errMsg2(err)}
|
|
141
|
+
`
|
|
142
|
+
);
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function parseScholarHtml(html, limit) {
|
|
147
|
+
const papers = [];
|
|
148
|
+
const h3Re = /<h3[^>]*class="[^"]*gs_rt[^"]*"[^>]*>([\s\S]*?)<\/h3>/gi;
|
|
149
|
+
let m;
|
|
150
|
+
while ((m = h3Re.exec(html)) !== null && papers.length < limit) {
|
|
151
|
+
const inner = m[1];
|
|
152
|
+
const anchor = inner.match(/<a[^>]*>([\s\S]*?)<\/a>/i);
|
|
153
|
+
const rawTitle = anchor ? anchor[1] : inner;
|
|
154
|
+
const title = stripTags(rawTitle).trim();
|
|
155
|
+
if (!title) continue;
|
|
156
|
+
const href = inner.match(/<a[^>]*href="([^"]+)"/i)?.[1];
|
|
157
|
+
papers.push({
|
|
158
|
+
id: href ?? title,
|
|
159
|
+
title,
|
|
160
|
+
authors: [],
|
|
161
|
+
url: href,
|
|
162
|
+
source: "scholar"
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return papers;
|
|
166
|
+
}
|
|
167
|
+
function stripTags(s) {
|
|
168
|
+
return s.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'").replace(/"/g, '"');
|
|
169
|
+
}
|
|
170
|
+
function errMsg2(err) {
|
|
171
|
+
return err instanceof Error ? err.message : String(err);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/backends/arxivsub.ts
|
|
175
|
+
var ENDPOINT3 = "https://arxivsub.com/api/search";
|
|
176
|
+
async function arxivsubSearch(query, limit, fetchImpl = fetch, env = process.env) {
|
|
177
|
+
const key = env.ARXIVSUB_SKILL_KEY;
|
|
178
|
+
if (!key) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const url = `${ENDPOINT3}?q=${encodeURIComponent(query)}&limit=${limit}`;
|
|
183
|
+
const res = await fetchImpl(url, {
|
|
184
|
+
headers: { authorization: `Bearer ${key}`, accept: "application/json" }
|
|
185
|
+
});
|
|
186
|
+
if (!res.ok) return [];
|
|
187
|
+
const json = await res.json();
|
|
188
|
+
const items = json.results ?? json.items ?? json.data ?? [];
|
|
189
|
+
if (!Array.isArray(items)) return [];
|
|
190
|
+
return items.slice(0, limit).map((item) => {
|
|
191
|
+
const authors = Array.isArray(item.authors) ? item.authors : typeof item.authors === "string" ? [item.authors] : [];
|
|
192
|
+
const yearNum = item.year !== void 0 ? Number(item.year) : void 0;
|
|
193
|
+
return {
|
|
194
|
+
id: String(item.id ?? item.arxiv_id ?? item.url ?? item.title ?? ""),
|
|
195
|
+
title: typeof item.title === "string" ? item.title : "",
|
|
196
|
+
authors,
|
|
197
|
+
year: yearNum !== void 0 && Number.isFinite(yearNum) ? yearNum : void 0,
|
|
198
|
+
abstract: item.abstract ?? item.summary,
|
|
199
|
+
url: typeof item.url === "string" ? item.url : void 0,
|
|
200
|
+
source: "arxivsub"
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
} catch (err) {
|
|
204
|
+
process.stderr.write(
|
|
205
|
+
`mcp-scholar: arxivsub backend degraded to []: ${errMsg3(err)}
|
|
206
|
+
`
|
|
207
|
+
);
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function errMsg3(err) {
|
|
212
|
+
return err instanceof Error ? err.message : String(err);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/facade.ts
|
|
216
|
+
var DEFAULT_BACKENDS = ["arxiv", "dblp"];
|
|
217
|
+
var DEFAULT_LIMIT = 10;
|
|
218
|
+
async function searchScholar(query, opts = {}) {
|
|
219
|
+
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
220
|
+
const backends = opts.backends ?? DEFAULT_BACKENDS;
|
|
221
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
222
|
+
const tasks = backends.map((name) => runBackend(name, query, limit, fetchImpl));
|
|
223
|
+
const results = await Promise.all(tasks);
|
|
224
|
+
return results.flat();
|
|
225
|
+
}
|
|
226
|
+
async function runBackend(name, query, limit, fetchImpl) {
|
|
227
|
+
try {
|
|
228
|
+
switch (name) {
|
|
229
|
+
case "arxiv":
|
|
230
|
+
return await arxivSearch(query, limit, fetchImpl);
|
|
231
|
+
case "dblp":
|
|
232
|
+
return await dblpSearch(query, limit, fetchImpl);
|
|
233
|
+
case "scholar":
|
|
234
|
+
return await scholarSearch(query, limit, fetchImpl);
|
|
235
|
+
case "arxivsub":
|
|
236
|
+
return await arxivsubSearch(query, limit, fetchImpl);
|
|
237
|
+
default:
|
|
238
|
+
process.stderr.write(`mcp-scholar: unknown backend "${name}" ignored
|
|
239
|
+
`);
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
244
|
+
process.stderr.write(
|
|
245
|
+
`mcp-scholar: backend "${name}" failed (isolated): ${message}
|
|
246
|
+
`
|
|
247
|
+
);
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/server.ts
|
|
253
|
+
function createServer() {
|
|
254
|
+
const server = new McpServer({
|
|
255
|
+
name: "mcp-scholar",
|
|
256
|
+
version: "0.0.0"
|
|
257
|
+
});
|
|
258
|
+
server.registerTool(
|
|
259
|
+
"scholar_search",
|
|
260
|
+
{
|
|
261
|
+
title: "Search scholarly literature",
|
|
262
|
+
description: "Search literature across multiple backends (default: arxiv + dblp; scholar and arxivsub are opt-in best-effort). Per-backend failures are isolated and every result is tagged with its source.",
|
|
263
|
+
inputSchema: {
|
|
264
|
+
query: z.string().describe("Free-text search query"),
|
|
265
|
+
limit: z.number().int().positive().optional().describe("Max results per backend (default 10)"),
|
|
266
|
+
backends: z.array(z.enum(["arxiv", "dblp", "scholar", "arxivsub"])).optional().describe('Backends to query (default ["arxiv","dblp"])')
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
async ({ query, limit, backends }) => {
|
|
270
|
+
try {
|
|
271
|
+
const papers = await searchScholar(query, { limit, backends });
|
|
272
|
+
return {
|
|
273
|
+
content: [{ type: "text", text: JSON.stringify(papers, null, 2) }],
|
|
274
|
+
structuredContent: { papers }
|
|
275
|
+
};
|
|
276
|
+
} catch (err) {
|
|
277
|
+
return errorResult("scholar_search", err);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
server.registerTool(
|
|
282
|
+
"scholar_metadata",
|
|
283
|
+
{
|
|
284
|
+
title: "Look up arXiv paper metadata",
|
|
285
|
+
description: 'Fetch metadata (title, authors, year, abstract, url) for a single arXiv id (e.g. "1706.03762").',
|
|
286
|
+
inputSchema: {
|
|
287
|
+
id: z.string().describe("arXiv id, e.g. 1706.03762 or 2005.14165v4")
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
async ({ id }) => {
|
|
291
|
+
try {
|
|
292
|
+
const paper = await arxivMetadata(id);
|
|
293
|
+
if (!paper) {
|
|
294
|
+
return {
|
|
295
|
+
content: [{ type: "text", text: `No arXiv metadata found for id "${id}"` }],
|
|
296
|
+
structuredContent: { paper: null }
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
content: [{ type: "text", text: JSON.stringify(paper, null, 2) }],
|
|
301
|
+
structuredContent: { paper }
|
|
302
|
+
};
|
|
303
|
+
} catch (err) {
|
|
304
|
+
return errorResult("scholar_metadata", err);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
);
|
|
308
|
+
server.registerTool(
|
|
309
|
+
"bibtex",
|
|
310
|
+
{
|
|
311
|
+
title: "Get a BibTeX entry",
|
|
312
|
+
description: "Return a BibTeX entry for a query. Resolves the top DBLP hit and fetches its .bib export. Returns an empty string when nothing is found.",
|
|
313
|
+
inputSchema: {
|
|
314
|
+
query: z.string().describe("Search query or DBLP record key")
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
async ({ query }) => {
|
|
318
|
+
try {
|
|
319
|
+
let key = query;
|
|
320
|
+
if (!looksLikeDblpKeyOrUrl(query)) {
|
|
321
|
+
const hits = await dblpSearch(query, 1);
|
|
322
|
+
if (hits.length > 0 && hits[0].id) {
|
|
323
|
+
key = hits[0].id;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const bib = await dblpBibtex(key);
|
|
327
|
+
return {
|
|
328
|
+
content: [{ type: "text", text: bib }],
|
|
329
|
+
structuredContent: { bibtex: bib }
|
|
330
|
+
};
|
|
331
|
+
} catch (err) {
|
|
332
|
+
return errorResult("bibtex", err);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
return server;
|
|
337
|
+
}
|
|
338
|
+
function looksLikeDblpKeyOrUrl(s) {
|
|
339
|
+
return /^https?:\/\//.test(s) || /^[a-z]+\/[^\s]+\/[^\s]+$/i.test(s);
|
|
340
|
+
}
|
|
341
|
+
function errorResult(tool, err) {
|
|
342
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
343
|
+
return {
|
|
344
|
+
isError: true,
|
|
345
|
+
content: [{ type: "text", text: `${tool} failed: ${message}` }]
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// bin/mcp-scholar.ts
|
|
350
|
+
async function main() {
|
|
351
|
+
const server = createServer();
|
|
352
|
+
const transport = new StdioServerTransport();
|
|
353
|
+
await server.connect(transport);
|
|
354
|
+
}
|
|
355
|
+
main().catch((err) => {
|
|
356
|
+
process.stderr.write(
|
|
357
|
+
`mcp-scholar: fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}
|
|
358
|
+
`
|
|
359
|
+
);
|
|
360
|
+
process.exit(1);
|
|
361
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@research-copilot/mcp-scholar",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for scholarly paper search",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-scholar": "./dist/mcp-scholar.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"scholar",
|
|
16
|
+
"arxiv",
|
|
17
|
+
"dblp",
|
|
18
|
+
"research"
|
|
19
|
+
],
|
|
20
|
+
"author": "ldm2060",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/ldm2060/research_copilot.git",
|
|
25
|
+
"directory": "packages/mcp-scholar"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
32
|
+
"fast-xml-parser": "^4.5.0",
|
|
33
|
+
"zod": "^3.25.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup bin/mcp-scholar.ts --format esm"
|
|
37
|
+
}
|
|
38
|
+
}
|