openscholar-mcp 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/mcp_server.js +424 -0
- package/package.json +16 -0
package/mcp_server.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* OpenScholar MCP Server (Node.js)
|
|
6
|
+
*
|
|
7
|
+
* Dependency-free stdio MCP wrapper for Claude Code. It exposes the same tools
|
|
8
|
+
* as mcp_server.py and forwards all tool calls to serve_openscholar.py:
|
|
9
|
+
*
|
|
10
|
+
* Claude Code <-> this file (stdio/MCP) <-> http://127.0.0.1:8006/api/mcp/tool
|
|
11
|
+
*
|
|
12
|
+
* Requires Node.js 18+ for built-in fetch.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const OPEN_SCHOLAR_URL = process.env.OPEN_SCHOLAR_URL || "http://127.0.0.1:8006";
|
|
16
|
+
const PROBES = Number.parseInt(process.env.PROBES || "20", 10);
|
|
17
|
+
const TOP_N = Number.parseInt(process.env.TOP_N || "8", 10);
|
|
18
|
+
|
|
19
|
+
const REQUEST_TIMEOUT_MS = 120000;
|
|
20
|
+
|
|
21
|
+
function log(message) {
|
|
22
|
+
process.stderr.write(`[openScholar MCP] ${message}\n`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function jsonText(value) {
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function postJson(url, payload, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
30
|
+
if (typeof fetch !== "function") {
|
|
31
|
+
throw new Error("Node.js 18+ is required because this server uses built-in fetch.");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch(url, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {"Content-Type": "application/json"},
|
|
41
|
+
body: JSON.stringify(payload),
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
});
|
|
44
|
+
const text = await response.text();
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`HTTP ${response.status} from ${url}: ${text}`);
|
|
47
|
+
}
|
|
48
|
+
return text ? JSON.parse(text) : {};
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function checkBackend() {
|
|
55
|
+
if (typeof fetch !== "function") {
|
|
56
|
+
log("Node.js 18+ is required because this server uses built-in fetch.");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(`${OPEN_SCHOLAR_URL}/api/health`, {
|
|
64
|
+
method: "GET",
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
});
|
|
67
|
+
if (response.status === 204) {
|
|
68
|
+
log(`Backend service reachable at ${OPEN_SCHOLAR_URL}`);
|
|
69
|
+
} else {
|
|
70
|
+
log(`Backend service returned HTTP ${response.status} at ${OPEN_SCHOLAR_URL}`);
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
log(`Backend service not reachable at ${OPEN_SCHOLAR_URL}. Ensure serve_openscholar.py is running before using MCP tools.`);
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function backendTool(tool, arguments_, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
80
|
+
try {
|
|
81
|
+
const response = await postJson(
|
|
82
|
+
`${OPEN_SCHOLAR_URL}/api/mcp/tool`,
|
|
83
|
+
{tool, arguments: arguments_},
|
|
84
|
+
timeoutMs,
|
|
85
|
+
);
|
|
86
|
+
return response.result;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const message = error && error.name === "AbortError"
|
|
89
|
+
? `${tool} request timed out.`
|
|
90
|
+
: `${tool} failed: ${error && error.message ? error.message : String(error)}`;
|
|
91
|
+
return jsonText({error: message});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function schema(properties, required = []) {
|
|
96
|
+
return {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties,
|
|
99
|
+
required,
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const stringSchema = (description, defaultValue) => {
|
|
105
|
+
const item = {type: "string", description};
|
|
106
|
+
if (defaultValue !== undefined) item.default = defaultValue;
|
|
107
|
+
return item;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const integerSchema = (description, defaultValue) => {
|
|
111
|
+
const item = {type: "integer", description};
|
|
112
|
+
if (defaultValue !== undefined) item.default = defaultValue;
|
|
113
|
+
return item;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const booleanSchema = (description, defaultValue) => {
|
|
117
|
+
const item = {type: "boolean", description};
|
|
118
|
+
if (defaultValue !== undefined) item.default = defaultValue;
|
|
119
|
+
return item;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const stringArraySchema = (description) => ({
|
|
123
|
+
type: "array",
|
|
124
|
+
items: {type: "string"},
|
|
125
|
+
description,
|
|
126
|
+
default: [],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const integerArraySchema = (description) => ({
|
|
130
|
+
type: "array",
|
|
131
|
+
items: {type: "integer"},
|
|
132
|
+
description,
|
|
133
|
+
default: [],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const tools = [
|
|
137
|
+
{
|
|
138
|
+
name: "search_papers",
|
|
139
|
+
description: `Search academic literature with dense retrieval + cross-encoder reranking.
|
|
140
|
+
|
|
141
|
+
Retrieves passages from PES2O (scientific corpus), reranks them with a cross-encoder model, and returns top-N results with metadata.
|
|
142
|
+
|
|
143
|
+
IMPORTANT: PES2O database is English-only. Translate Chinese or non-English queries into English before searching.`,
|
|
144
|
+
inputSchema: schema({
|
|
145
|
+
query: stringSchema("The search query in English. Translate non-English queries first."),
|
|
146
|
+
top_n: integerSchema("Number of top results to return.", TOP_N),
|
|
147
|
+
probes: integerSchema("ivfflat probes for PES2O retrieval; controls search width.", PROBES),
|
|
148
|
+
}, ["query"]),
|
|
149
|
+
call: (args) => backendTool("search_papers", {
|
|
150
|
+
query: args.query,
|
|
151
|
+
top_n: args.top_n ?? TOP_N,
|
|
152
|
+
probes: args.probes ?? PROBES,
|
|
153
|
+
}),
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "get_paper_details",
|
|
157
|
+
description: `Get bibliographic metadata for formatting reference entries.
|
|
158
|
+
|
|
159
|
+
Use this for standard reference formats such as GB/T 7714, APA, MLA, and BibTeX-style entries. It is not intended as evidence text for LLM generation.`,
|
|
160
|
+
inputSchema: schema({
|
|
161
|
+
corpus_ids: integerArraySchema("Paper corpus IDs from search results."),
|
|
162
|
+
dois: stringArraySchema("DOIs from search results; recommended for best metadata."),
|
|
163
|
+
titles: stringArraySchema("Paper titles from search results; recommended for best metadata."),
|
|
164
|
+
}),
|
|
165
|
+
call: (args) => backendTool("get_paper_details", {
|
|
166
|
+
corpus_ids: args.corpus_ids || [],
|
|
167
|
+
dois: args.dois || [],
|
|
168
|
+
titles: args.titles || [],
|
|
169
|
+
}, 30000),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "generate_review",
|
|
173
|
+
description: `Generate a comprehensive literature review prompt with citations on a research topic.
|
|
174
|
+
|
|
175
|
+
This is the main tool for writing academic reviews. It searches PES2O, reranks results, enriches metadata, and returns a complete prompt with references. Claude must then write the review from that prompt.`,
|
|
176
|
+
inputSchema: schema({
|
|
177
|
+
question: stringSchema("The research question to review. Can be Chinese or English."),
|
|
178
|
+
top_n: integerSchema("Number of top passages to include as references.", TOP_N),
|
|
179
|
+
probes: integerSchema("ivfflat probes for PES2O retrieval; controls search width.", PROBES),
|
|
180
|
+
language: stringSchema("Output language: en for English, cn for Chinese.", "en"),
|
|
181
|
+
zero_shot: booleanSchema("If true, skip few-shot examples.", false),
|
|
182
|
+
}, ["question"]),
|
|
183
|
+
call: (args) => backendTool("generate_review", {
|
|
184
|
+
question: args.question,
|
|
185
|
+
top_n: args.top_n ?? TOP_N,
|
|
186
|
+
probes: args.probes ?? PROBES,
|
|
187
|
+
language: args.language || "en",
|
|
188
|
+
zero_shot: args.zero_shot ?? false,
|
|
189
|
+
}),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: "generate_related_work",
|
|
193
|
+
description: `Search literature and build a prompt for generating a related work section.
|
|
194
|
+
|
|
195
|
+
Given a paper abstract, this tool searches for related papers and returns a prompt for writing related work with citations.`,
|
|
196
|
+
inputSchema: schema({
|
|
197
|
+
abstract: stringSchema("The abstract of the paper. English is recommended for best retrieval."),
|
|
198
|
+
top_n: integerSchema("Number of related passages to include.", TOP_N),
|
|
199
|
+
probes: integerSchema("ivfflat probes for PES2O retrieval; controls search width.", PROBES),
|
|
200
|
+
language: stringSchema("Output language: en for English, cn for Chinese.", "en"),
|
|
201
|
+
}, ["abstract"]),
|
|
202
|
+
call: (args) => backendTool("generate_related_work", {
|
|
203
|
+
abstract: args.abstract,
|
|
204
|
+
top_n: args.top_n ?? TOP_N,
|
|
205
|
+
probes: args.probes ?? PROBES,
|
|
206
|
+
language: args.language || "en",
|
|
207
|
+
}),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "generate_feedback",
|
|
211
|
+
description: "Build a prompt for evaluating an answer and generating improvement suggestions.",
|
|
212
|
+
inputSchema: schema({
|
|
213
|
+
question: stringSchema("The original research question."),
|
|
214
|
+
answer: stringSchema("The current answer to evaluate."),
|
|
215
|
+
language: stringSchema("Output language: en for English, cn for Chinese.", "en"),
|
|
216
|
+
}, ["question", "answer"]),
|
|
217
|
+
call: (args) => backendTool("generate_feedback", {
|
|
218
|
+
question: args.question,
|
|
219
|
+
answer: args.answer,
|
|
220
|
+
language: args.language || "en",
|
|
221
|
+
}, 30000),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: "edit_with_feedback",
|
|
225
|
+
description: "Build a prompt to incorporate feedback and improve an answer.",
|
|
226
|
+
inputSchema: schema({
|
|
227
|
+
question: stringSchema("The original research question."),
|
|
228
|
+
answer: stringSchema("The current answer."),
|
|
229
|
+
feedback: stringSchema("The feedback to incorporate."),
|
|
230
|
+
new_references: stringSchema("Additional references from supplemental retrieval.", ""),
|
|
231
|
+
language: stringSchema("Output language: en for English, cn for Chinese.", "en"),
|
|
232
|
+
}, ["question", "answer", "feedback"]),
|
|
233
|
+
call: (args) => backendTool("edit_with_feedback", {
|
|
234
|
+
question: args.question,
|
|
235
|
+
answer: args.answer,
|
|
236
|
+
feedback: args.feedback,
|
|
237
|
+
new_references: args.new_references || "",
|
|
238
|
+
language: args.language || "en",
|
|
239
|
+
}, 30000),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: "add_citations",
|
|
243
|
+
description: "Build a prompt for post-hoc citation insertion into an uncited paragraph.",
|
|
244
|
+
inputSchema: schema({
|
|
245
|
+
paragraph: stringSchema("The paragraph without citations."),
|
|
246
|
+
references: stringSchema("Reference passages formatted as [N] Title: ... Text: ..."),
|
|
247
|
+
language: stringSchema("Output language: en for English, cn for Chinese.", "en"),
|
|
248
|
+
}, ["paragraph", "references"]),
|
|
249
|
+
call: (args) => backendTool("add_citations", {
|
|
250
|
+
paragraph: args.paragraph,
|
|
251
|
+
references: args.references,
|
|
252
|
+
language: args.language || "en",
|
|
253
|
+
}, 30000),
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: "refine_answer",
|
|
257
|
+
description: "Build a prompt for final answer refinement while preserving citations.",
|
|
258
|
+
inputSchema: schema({
|
|
259
|
+
question: stringSchema("The original research question."),
|
|
260
|
+
answer: stringSchema("The answer to refine."),
|
|
261
|
+
language: stringSchema("Output language: en for English, cn for Chinese.", "en"),
|
|
262
|
+
}, ["question", "answer"]),
|
|
263
|
+
call: (args) => backendTool("refine_answer", {
|
|
264
|
+
question: args.question,
|
|
265
|
+
answer: args.answer,
|
|
266
|
+
language: args.language || "en",
|
|
267
|
+
}, 30000),
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
const toolsByName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
272
|
+
|
|
273
|
+
function listTools() {
|
|
274
|
+
return tools.map((tool) => ({
|
|
275
|
+
name: tool.name,
|
|
276
|
+
description: tool.description,
|
|
277
|
+
inputSchema: tool.inputSchema,
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function response(id, result) {
|
|
282
|
+
return {jsonrpc: "2.0", id, result};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function errorResponse(id, code, message) {
|
|
286
|
+
return {jsonrpc: "2.0", id, error: {code, message}};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function writeMessage(message) {
|
|
290
|
+
const payload = Buffer.from(JSON.stringify(message), "utf8");
|
|
291
|
+
process.stdout.write(`Content-Length: ${payload.length}\r\n\r\n`);
|
|
292
|
+
process.stdout.write(payload);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function callTool(params) {
|
|
296
|
+
const name = params && params.name;
|
|
297
|
+
const args = (params && params.arguments) || {};
|
|
298
|
+
const tool = toolsByName.get(name);
|
|
299
|
+
if (!tool) {
|
|
300
|
+
return {
|
|
301
|
+
content: [{type: "text", text: `Unknown tool: ${name}`}],
|
|
302
|
+
isError: true,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const result = await tool.call(args);
|
|
308
|
+
return {
|
|
309
|
+
content: [{type: "text", text: String(result)}],
|
|
310
|
+
isError: false,
|
|
311
|
+
};
|
|
312
|
+
} catch (error) {
|
|
313
|
+
log(`Tool ${name} failed: ${error && error.stack ? error.stack : error}`);
|
|
314
|
+
return {
|
|
315
|
+
content: [{type: "text", text: `${error && error.name ? error.name : "Error"}: ${error && error.message ? error.message : String(error)}`}],
|
|
316
|
+
isError: true,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function handleMessage(message) {
|
|
322
|
+
const id = message.id;
|
|
323
|
+
if (id === undefined || id === null) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const params = message.params || {};
|
|
329
|
+
switch (message.method) {
|
|
330
|
+
case "initialize":
|
|
331
|
+
writeMessage(response(id, {
|
|
332
|
+
protocolVersion: params.protocolVersion || "2024-11-05",
|
|
333
|
+
capabilities: {tools: {listChanged: false}},
|
|
334
|
+
serverInfo: {name: "openScholar", version: "1.0.0-node"},
|
|
335
|
+
}));
|
|
336
|
+
break;
|
|
337
|
+
case "ping":
|
|
338
|
+
writeMessage(response(id, {}));
|
|
339
|
+
break;
|
|
340
|
+
case "tools/list":
|
|
341
|
+
writeMessage(response(id, {tools: listTools()}));
|
|
342
|
+
break;
|
|
343
|
+
case "tools/call":
|
|
344
|
+
writeMessage(response(id, await callTool(params)));
|
|
345
|
+
break;
|
|
346
|
+
case "resources/list":
|
|
347
|
+
writeMessage(response(id, {resources: []}));
|
|
348
|
+
break;
|
|
349
|
+
case "prompts/list":
|
|
350
|
+
writeMessage(response(id, {prompts: []}));
|
|
351
|
+
break;
|
|
352
|
+
default:
|
|
353
|
+
writeMessage(errorResponse(id, -32601, `Method not found: ${message.method}`));
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
log(`Failed to handle MCP message: ${error && error.stack ? error.stack : error}`);
|
|
358
|
+
writeMessage(errorResponse(id, -32603, "Internal error"));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let inputBuffer = Buffer.alloc(0);
|
|
363
|
+
let processingQueue = Promise.resolve();
|
|
364
|
+
|
|
365
|
+
function enqueueMessage(message) {
|
|
366
|
+
processingQueue = processingQueue
|
|
367
|
+
.then(() => handleMessage(message))
|
|
368
|
+
.catch((error) => {
|
|
369
|
+
log(`Failed to process queued MCP message: ${error && error.stack ? error.stack : error}`);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parseMessages() {
|
|
374
|
+
while (true) {
|
|
375
|
+
const headerEnd = inputBuffer.indexOf("\r\n\r\n");
|
|
376
|
+
const altHeaderEnd = inputBuffer.indexOf("\n\n");
|
|
377
|
+
let separatorLength = 4;
|
|
378
|
+
let index = headerEnd;
|
|
379
|
+
|
|
380
|
+
if (index < 0 || (altHeaderEnd >= 0 && altHeaderEnd < index)) {
|
|
381
|
+
index = altHeaderEnd;
|
|
382
|
+
separatorLength = 2;
|
|
383
|
+
}
|
|
384
|
+
if (index < 0) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const headerText = inputBuffer.slice(0, index).toString("utf8");
|
|
389
|
+
const match = headerText.match(/content-length:\s*(\d+)/i);
|
|
390
|
+
if (!match) {
|
|
391
|
+
inputBuffer = inputBuffer.slice(index + separatorLength);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const length = Number.parseInt(match[1], 10);
|
|
396
|
+
const messageStart = index + separatorLength;
|
|
397
|
+
const messageEnd = messageStart + length;
|
|
398
|
+
if (inputBuffer.length < messageEnd) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const payload = inputBuffer.slice(messageStart, messageEnd).toString("utf8");
|
|
403
|
+
inputBuffer = inputBuffer.slice(messageEnd);
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
const message = JSON.parse(payload);
|
|
407
|
+
enqueueMessage(message);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
log(`Failed to parse MCP message: ${error && error.stack ? error.stack : error}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
process.stdin.on("data", (chunk) => {
|
|
415
|
+
inputBuffer = Buffer.concat([inputBuffer, chunk]);
|
|
416
|
+
parseMessages();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
process.stdin.on("end", async () => {
|
|
420
|
+
await processingQueue;
|
|
421
|
+
process.exit(0);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
checkBackend();
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openscholar-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenScholar MCP Server - academic literature search and review for Claude Code",
|
|
5
|
+
"main": "mcp_server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"openscholar-mcp": "mcp_server.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"mcp_server.js"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT"
|
|
16
|
+
}
|