mirror of
https://github.com/getcompanion-ai/alpha-hub.git
synced 2026-04-16 20:01:23 +00:00
Alpha Hub: Context Hub for research papers
CLI + MCP server for searching papers and building persistent knowledge. Powered by alphaXiv for semantic search, paper reports, and PDF Q&A. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9a708a1ab9
20 changed files with 2212 additions and 0 deletions
84
cli/src/mcp/server.js
Normal file
84
cli/src/mcp/server.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { handleSearch, handleGet, handleAsk, handleAnnotate, handleCode } from './tools.js';
|
||||
|
||||
const _stderr = process.stderr;
|
||||
console.log = (...args) => _stderr.write(args.join(' ') + '\n');
|
||||
console.warn = (...args) => _stderr.write('[warn] ' + args.join(' ') + '\n');
|
||||
console.info = (...args) => _stderr.write('[info] ' + args.join(' ') + '\n');
|
||||
console.debug = (...args) => _stderr.write('[debug] ' + args.join(' ') + '\n');
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'alpha',
|
||||
version: pkg.version,
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'alpha_search',
|
||||
'Search research papers via alphaXiv. Supports semantic (embedding), keyword, and agentic search modes.',
|
||||
{
|
||||
query: z.string().describe('Search query — use 2-3 sentences for semantic mode, keywords for keyword mode'),
|
||||
mode: z.enum(['semantic', 'keyword', 'agentic']).optional().describe('Search mode (default: semantic)'),
|
||||
},
|
||||
async (args) => handleSearch(args),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'alpha_get',
|
||||
'Get paper content and local annotation. Accepts arXiv URL, alphaXiv URL, or arXiv ID.',
|
||||
{
|
||||
url: z.string().describe('arXiv/alphaXiv URL or arXiv ID (e.g. "2106.09685")'),
|
||||
full_text: z.boolean().optional().describe('Get raw text instead of AI-generated report (default: false)'),
|
||||
},
|
||||
async (args) => handleGet(args),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'alpha_ask',
|
||||
'Ask a question about a specific paper. Uses AI to analyze the PDF and answer.',
|
||||
{
|
||||
url: z.string().describe('arXiv/alphaXiv URL or arXiv ID'),
|
||||
question: z.string().describe('Question about the paper'),
|
||||
},
|
||||
async (args) => handleAsk(args),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'alpha_annotate',
|
||||
'Read, write, clear, or list local annotations on papers. Annotations persist across sessions and appear on future fetches.',
|
||||
{
|
||||
id: z.string().optional().describe('Paper ID (arXiv ID or URL). Required unless using list mode.'),
|
||||
note: z.string().optional().describe('Annotation text to save. Omit to read existing.'),
|
||||
clear: z.boolean().optional().describe('Remove annotation for this paper'),
|
||||
list: z.boolean().optional().describe('List all annotations'),
|
||||
},
|
||||
async (args) => handleAnnotate(args),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'alpha_code',
|
||||
"Read files from a paper's GitHub repository. Use path '/' for repo overview.",
|
||||
{
|
||||
github_url: z.string().describe('GitHub repository URL'),
|
||||
path: z.string().optional().describe("File or directory path (default: '/')"),
|
||||
},
|
||||
async (args) => handleCode(args),
|
||||
);
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
_stderr.write(`[alpha-mcp] Uncaught exception: ${err.message}\n`);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
_stderr.write(`[alpha-mcp] Unhandled rejection: ${reason}\n`);
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
_stderr.write(`[alpha-mcp] Server started (v${pkg.version})\n`);
|
||||
94
cli/src/mcp/tools.js
Normal file
94
cli/src/mcp/tools.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import {
|
||||
searchByEmbedding,
|
||||
searchByKeyword,
|
||||
agenticSearch,
|
||||
getPaperContent,
|
||||
answerPdfQuery,
|
||||
readGithubRepo,
|
||||
} from '../lib/alphaxiv.js';
|
||||
import { writeAnnotation, readAnnotation, clearAnnotation, listAnnotations } from '../lib/annotations.js';
|
||||
import { normalizePaperId, toArxivUrl } from '../lib/papers.js';
|
||||
|
||||
function textResult(data) {
|
||||
return {
|
||||
content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
function errorResult(message) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSearch({ query, mode = 'semantic' }) {
|
||||
try {
|
||||
if (mode === 'keyword') return textResult(await searchByKeyword(query));
|
||||
if (mode === 'agentic') return textResult(await agenticSearch(query));
|
||||
return textResult(await searchByEmbedding(query));
|
||||
} catch (err) {
|
||||
return errorResult(`Search failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGet({ url, full_text = false }) {
|
||||
try {
|
||||
const paperId = normalizePaperId(url);
|
||||
const arxivUrl = toArxivUrl(url);
|
||||
|
||||
const content = await getPaperContent(arxivUrl, { fullText: full_text });
|
||||
const annotation = readAnnotation(paperId);
|
||||
|
||||
return textResult({ content, annotation });
|
||||
} catch (err) {
|
||||
return errorResult(`Failed to fetch paper: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAsk({ url, question }) {
|
||||
try {
|
||||
const answer = await answerPdfQuery(toArxivUrl(url), question);
|
||||
return textResult(answer);
|
||||
} catch (err) {
|
||||
return errorResult(`Ask failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAnnotate({ id, note, clear = false, list = false }) {
|
||||
try {
|
||||
if (list) {
|
||||
const all = listAnnotations();
|
||||
return textResult({ annotations: all, total: all.length });
|
||||
}
|
||||
|
||||
if (!id) return errorResult('Provide a paper ID or use list mode.');
|
||||
|
||||
const paperId = normalizePaperId(id);
|
||||
|
||||
if (clear) {
|
||||
const removed = clearAnnotation(paperId);
|
||||
return textResult({ status: removed ? 'cleared' : 'not_found', id: paperId });
|
||||
}
|
||||
|
||||
if (note) {
|
||||
const saved = writeAnnotation(paperId, note);
|
||||
return textResult({ status: 'saved', annotation: saved });
|
||||
}
|
||||
|
||||
const existing = readAnnotation(paperId);
|
||||
if (existing) return textResult({ annotation: existing });
|
||||
return textResult({ status: 'no_annotation', id: paperId });
|
||||
} catch (err) {
|
||||
return errorResult(`Annotation failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCode({ github_url, path = '/' }) {
|
||||
try {
|
||||
const result = await readGithubRepo(github_url, path);
|
||||
return textResult(result);
|
||||
} catch (err) {
|
||||
return errorResult(`Code read failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue