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:
Advait Paliwal 2026-03-18 16:53:26 -07:00
commit 9a708a1ab9
20 changed files with 2212 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
.env.local
.env

99
README.md Normal file
View file

@ -0,0 +1,99 @@
# Alpha Hub
Research agents hallucinate paper details and forget what they learn in a session. Alpha Hub gives them semantic paper search, AI-generated reports, and persistent annotations — so they get smarter with every task. Search and content powered by [alphaXiv](https://alphaxiv.org).
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
## Quick Start
```bash
npm install -g @alpha-hub/cli
alpha login # sign in with alphaXiv
alpha search "attention mechanism" # search papers
alpha get 1706.03762 # fetch paper report
```
## How It Works
Alpha is designed for your coding agent to use (not for you to use!). You can prompt your agent to use it (e.g., "Use the CLI command `alpha` to search for papers on LoRA. Run `alpha` to see how it works.")
**Most of the time, it's simple — search, read, use:**
```bash
alpha search "transformer attention" # find relevant papers
alpha get 1706.03762 # fetch AI-generated paper report
# Agent reads the report, understands the paper. Done.
```
**When the agent discovers something useful**, it can annotate locally for next time:
```bash
alpha annotate 1706.03762 "Superseded by Flash Attention for efficiency"
# Next session, the annotation appears automatically on alpha get.
```
**Need to go deeper?** Ask questions about any paper:
```bash
alpha ask 1706.03762 "What datasets were used for evaluation?"
```
## Commands
| Command | Purpose |
|---------|---------|
| `alpha search <query>` | Search papers (semantic, keyword, or agentic) |
| `alpha get <id\|url>` | Fetch paper report + local annotation |
| `alpha ask <id\|url> <question>` | Ask a question about a paper |
| `alpha annotate <id> <note>` | Attach a note to a paper |
| `alpha annotate <id> --clear` | Remove a note |
| `alpha annotate --list` | List all notes |
| `alpha login` | Sign in with alphaXiv |
| `alpha logout` | Sign out |
All commands accept `--json` for machine-readable output.
## Self-Improving Agents
Alpha Hub is designed for a loop where agents get better over time.
**Annotations** are local notes that agents attach to papers. They persist across sessions and appear automatically on future fetches — so agents learn from past experience.
```
Without Alpha Hub With Alpha Hub
───────────────── ──────────────
Search the web for papers Semantic search via alphaXiv
Read raw PDFs AI-generated paper reports
Miss context and gotchas Agent notes what it learns
Knowledge forgotten ↗ Even smarter next session
↻ Repeat next session
```
## Key Features
### Semantic Search
Three search modes — semantic (embedding similarity), keyword (exact terms), and agentic (multi-turn retrieval) — so agents find the right papers regardless of how they phrase the query.
```bash
alpha search "methods for reducing hallucination in LLMs" # semantic
alpha search "LoRA" --mode keyword # keyword
```
### Paper Q&A
Ask questions about any paper without reading the full PDF. The answer is grounded in the paper's actual content.
```bash
alpha ask 2106.09685 "What is the rank used for the low-rank matrices?"
```
### Annotations
Local notes that agents attach to papers — they persist across sessions and appear automatically on future fetches. See the annotation as a gap the agent discovered and recorded so it doesn't repeat the same mistake.
## License
[MIT](LICENSE)

2
cli/bin/alpha Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../src/index.js';

2
cli/bin/alpha-mcp Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../src/mcp/server.js';

1170
cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
cli/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "@alpha-hub/cli",
"version": "0.1.0",
"description": "CLI for Alpha Hub - search papers and share community annotations",
"type": "module",
"bin": {
"alpha": "./bin/alpha",
"alpha-mcp": "./bin/alpha-mcp"
},
"files": [
"bin/",
"src/"
],
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"research",
"papers",
"annotations",
"alphaxiv",
"mcp",
"cli"
],
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"zod": "^3.23.0"
}
}

View file

@ -0,0 +1,65 @@
import chalk from 'chalk';
import { writeAnnotation, readAnnotation, clearAnnotation, listAnnotations } from '../lib/annotations.js';
import { normalizePaperId } from '../lib/papers.js';
import { output, error } from '../lib/output.js';
function formatList(annotations) {
if (annotations.length === 0) {
console.log(chalk.dim('No annotations.'));
return;
}
annotations.forEach(a => {
console.log(`${chalk.bold(a.id)} ${chalk.dim(`(${a.updatedAt})`)}`);
console.log(` ${a.note}`);
console.log();
});
}
export function registerAnnotateCommand(program) {
program
.command('annotate [paper-id] [note]')
.description('Read, write, or list local annotations')
.option('--clear', 'Remove annotation for this paper')
.option('--list', 'List all annotations')
.action(async (paperId, note, cmdOpts) => {
const opts = { ...program.opts(), ...cmdOpts };
if (opts.list) {
const all = listAnnotations();
output(all, formatList, opts);
return;
}
if (!paperId) {
error('Provide a paper ID, or use --list', opts);
}
const id = normalizePaperId(paperId);
if (opts.clear) {
const removed = clearAnnotation(id);
if (removed) {
console.log(chalk.green(`Cleared annotation for ${id}`));
} else {
console.log(chalk.dim(`No annotation for ${id}`));
}
return;
}
if (note) {
const saved = writeAnnotation(id, note);
output(saved, () => console.log(chalk.green(`Annotation saved for ${id}`)), opts);
return;
}
const existing = readAnnotation(id);
if (existing) {
output(existing, () => {
console.log(`${chalk.bold(existing.id)} ${chalk.dim(`(${existing.updatedAt})`)}`);
console.log(` ${existing.note}`);
}, opts);
} else {
console.log(chalk.dim(`No annotation for ${id}`));
}
});
}

24
cli/src/commands/ask.js Normal file
View file

@ -0,0 +1,24 @@
import { answerPdfQuery, disconnect } from '../lib/alphaxiv.js';
import { output, error } from '../lib/output.js';
import { toArxivUrl } from '../lib/papers.js';
function formatAnswer(data) {
console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
}
export function registerAskCommand(program) {
program
.command('ask <url> <question>')
.description('Ask a question about a paper (arXiv/alphaXiv URL or ID)')
.action(async (url, question, cmdOpts) => {
const opts = { ...program.opts(), ...cmdOpts };
try {
const answer = await answerPdfQuery(toArxivUrl(url), question);
output(answer, formatAnswer, opts);
} catch (err) {
error(err.message, opts);
} finally {
await disconnect();
}
});
}

39
cli/src/commands/get.js Normal file
View file

@ -0,0 +1,39 @@
import chalk from 'chalk';
import { getPaperContent, disconnect } from '../lib/alphaxiv.js';
import { readAnnotation } from '../lib/annotations.js';
import { output, error } from '../lib/output.js';
import { normalizePaperId, toArxivUrl } from '../lib/papers.js';
function formatPaper({ content, annotation }) {
console.log(typeof content === 'string' ? content : JSON.stringify(content, null, 2));
if (annotation) {
console.log();
console.log(chalk.dim('---'));
console.log(chalk.dim(`[Note — ${annotation.updatedAt}]`));
console.log(annotation.note);
}
}
export function registerGetCommand(program) {
program
.command('get <url>')
.description('Get paper content + local annotation (arXiv/alphaXiv URL or ID)')
.option('--full-text', 'Get raw extracted text instead of AI report')
.action(async (url, cmdOpts) => {
const opts = { ...program.opts(), ...cmdOpts };
try {
const paperId = normalizePaperId(url);
const arxivUrl = toArxivUrl(url);
const content = await getPaperContent(arxivUrl, { fullText: !!opts.fullText });
const annotation = readAnnotation(paperId);
output({ content, annotation }, formatPaper, opts);
} catch (err) {
error(err.message, opts);
} finally {
await disconnect();
}
});
}

31
cli/src/commands/login.js Normal file
View file

@ -0,0 +1,31 @@
import chalk from 'chalk';
import { login, isLoggedIn, logout } from '../lib/auth.js';
export function registerLoginCommand(program) {
program
.command('login')
.description('Log in to alphaXiv (opens browser)')
.action(async () => {
try {
if (isLoggedIn()) {
process.stderr.write(chalk.dim('Already logged in. Use `alpha logout` to sign out first.\n'));
}
const { userInfo } = await login();
const name = userInfo?.name || userInfo?.email || 'unknown';
console.log(chalk.green(`Logged in to alphaXiv as ${name}`));
} catch (err) {
process.stderr.write(`${chalk.red('Login failed:')} ${err.message}\n`);
process.exit(1);
}
});
}
export function registerLogoutCommand(program) {
program
.command('logout')
.description('Log out of alphaXiv')
.action(() => {
logout();
console.log(chalk.green('Logged out'));
});
}

View file

@ -0,0 +1,37 @@
import chalk from 'chalk';
import { searchByEmbedding, searchByKeyword, disconnect } from '../lib/alphaxiv.js';
import { output, error } from '../lib/output.js';
function formatResults(data) {
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
console.log(text);
}
export function registerSearchCommand(program) {
program
.command('search <query>')
.description('Search papers via alphaXiv (semantic + keyword)')
.option('-m, --mode <mode>', 'Search mode: semantic, keyword, both', 'semantic')
.action(async (query, cmdOpts) => {
const opts = { ...program.opts(), ...cmdOpts };
try {
let results;
if (opts.mode === 'keyword') {
results = await searchByKeyword(query);
} else if (opts.mode === 'both') {
const [semantic, keyword] = await Promise.all([
searchByEmbedding(query),
searchByKeyword(query),
]);
results = { semantic, keyword };
} else {
results = await searchByEmbedding(query);
}
output(results, formatResults, opts);
} catch (err) {
error(err.message, opts);
} finally {
await disconnect();
}
});
}

67
cli/src/index.js Normal file
View file

@ -0,0 +1,67 @@
import chalk from 'chalk';
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { registerSearchCommand } from './commands/search.js';
import { registerGetCommand } from './commands/get.js';
import { registerAskCommand } from './commands/ask.js';
import { registerAnnotateCommand } from './commands/annotate.js';
import { registerLoginCommand, registerLogoutCommand } from './commands/login.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
function printUsage() {
console.log(`
${chalk.bold('alpha')} Alpha Hub CLI v${pkg.version}
Search papers and annotate what you learn. Powered by alphaXiv.
${chalk.bold.underline('Usage')}
${chalk.dim('$')} alpha search "transformer attention mechanisms" ${chalk.dim('# semantic search')}
${chalk.dim('$')} alpha search "LoRA" --mode keyword ${chalk.dim('# keyword search')}
${chalk.dim('$')} alpha get 1706.03762 ${chalk.dim('# paper content + annotation')}
${chalk.dim('$')} alpha get https://arxiv.org/abs/2106.09685 ${chalk.dim('# by URL')}
${chalk.dim('$')} alpha ask 1706.03762 "How does attention work?" ${chalk.dim('# ask about a paper')}
${chalk.dim('$')} alpha annotate 1706.03762 "key insight" ${chalk.dim('# save a note')}
${chalk.dim('$')} alpha annotate --list ${chalk.dim('# see all notes')}
${chalk.bold.underline('Commands')}
${chalk.bold('login')} Log in to alphaXiv (opens browser)
${chalk.bold('logout')} Log out
${chalk.bold('search')} <query> Search papers (semantic, keyword, or agentic)
${chalk.bold('get')} <url|arxiv-id> Paper content + local annotation
${chalk.bold('ask')} <url|arxiv-id> <question> Ask a question about a paper
${chalk.bold('annotate')} [paper-id] [note] Save a note appears on future fetches
${chalk.bold('annotate')} <paper-id> --clear Remove a note
${chalk.bold('annotate')} --list List all notes
${chalk.bold.underline('Flags')}
--json JSON output (for agents and piping)
-m, --mode <mode> Search mode: semantic, keyword, both (default: semantic)
--full-text Get raw text instead of AI report (for get)
`);
}
const program = new Command();
program
.name('alpha')
.description('Alpha Hub - search papers and annotate what you learn')
.version(pkg.version, '-V, --cli-version')
.option('--json', 'Output as JSON (machine-readable)')
.action(() => {
printUsage();
});
registerLoginCommand(program);
registerLogoutCommand(program);
registerSearchCommand(program);
registerGetCommand(program);
registerAskCommand(program);
registerAnnotateCommand(program);
program.parse();

110
cli/src/lib/alphaxiv.js Normal file
View file

@ -0,0 +1,110 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { getValidToken, refreshAccessToken } from './auth.js';
const ALPHAXIV_MCP_URL = 'https://api.alphaxiv.org/mcp/v1';
let _client = null;
let _connected = false;
async function getClient() {
if (_client && _connected) return _client;
const token = await getValidToken();
if (!token) {
throw new Error('Not logged in. Run `alpha login` first.');
}
_client = new Client({ name: 'alpha', version: '0.1.0' });
_client.onerror = (err) => {
process.stderr.write(`[alpha] alphaXiv MCP error: ${err.message || err}\n`);
};
const transport = new StreamableHTTPClientTransport(new URL(ALPHAXIV_MCP_URL), {
requestInit: {
headers: {
Authorization: `Bearer ${token}`,
},
},
});
await _client.connect(transport);
_connected = true;
return _client;
}
async function callTool(name, args) {
let client;
try {
client = await getClient();
} catch (err) {
if (err.message?.includes('401') || err.message?.includes('Unauthorized')) {
const newToken = await refreshAccessToken();
if (newToken) {
_client = null;
_connected = false;
client = await getClient();
} else {
throw new Error('Session expired. Run `alpha login` to re-authenticate.');
}
} else {
throw err;
}
}
const result = await client.callTool({ name, arguments: args });
if (result.isError) {
const text = result.content?.[0]?.text || 'Unknown error';
throw new Error(text);
}
const text = result.content?.[0]?.text;
if (!text) return result.content;
try {
return JSON.parse(text);
} catch {
return text;
}
}
export async function searchByEmbedding(query) {
return await callTool('embedding_similarity_search', { query });
}
export async function searchByKeyword(query) {
return await callTool('full_text_papers_search', { query });
}
export async function agenticSearch(query) {
return await callTool('agentic_paper_retrieval', { query });
}
export async function getPaperContent(url, { fullText = false } = {}) {
const args = { url };
if (fullText) args.fullText = true;
return await callTool('get_paper_content', args);
}
export async function answerPdfQuery(url, query) {
return await callTool('answer_pdf_queries', { url, query });
}
export async function readGithubRepo(githubUrl, path = '/') {
return await callTool('read_files_from_github_repository', { githubUrl, path });
}
export async function disconnect() {
if (_client) {
_client.onerror = () => {};
try {
await _client.close();
} catch {
}
_client = null;
_connected = false;
}
}

View file

@ -0,0 +1,57 @@
import { readFileSync, writeFileSync, unlinkSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
function getAnnotationsDir() {
const dir = join(homedir(), '.ahub', 'annotations');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
}
function safeFilename(id) {
return id.replace(/\//g, '--') + '.json';
}
export function writeAnnotation(id, note) {
const filePath = join(getAnnotationsDir(), safeFilename(id));
const data = {
id,
note,
updatedAt: new Date().toISOString(),
};
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
return data;
}
export function readAnnotation(id) {
try {
const filePath = join(getAnnotationsDir(), safeFilename(id));
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
export function clearAnnotation(id) {
try {
const filePath = join(getAnnotationsDir(), safeFilename(id));
unlinkSync(filePath);
return true;
} catch {
return false;
}
}
export function listAnnotations() {
const dir = getAnnotationsDir();
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
const annotations = [];
for (const file of files) {
try {
const data = JSON.parse(readFileSync(join(dir, file), 'utf8'));
if (data.id && data.note) annotations.push(data);
} catch {
}
}
return annotations;
}

245
cli/src/lib/auth.js Normal file
View file

@ -0,0 +1,245 @@
import { createHash, randomBytes } from 'node:crypto';
import { createServer } from 'node:http';
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { execSync } from 'node:child_process';
import { platform } from 'node:os';
const CLERK_ISSUER = 'https://clerk.alphaxiv.org';
const AUTH_ENDPOINT = `${CLERK_ISSUER}/oauth/authorize`;
const TOKEN_ENDPOINT = `${CLERK_ISSUER}/oauth/token`;
const REGISTER_ENDPOINT = `${CLERK_ISSUER}/oauth/register`;
const CALLBACK_PORT = 9876;
const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
const USERINFO_ENDPOINT = `${CLERK_ISSUER}/oauth/userinfo`;
const SCOPES = 'profile email offline_access';
function getAuthPath() {
const dir = join(homedir(), '.ahub');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return join(dir, 'auth.json');
}
function loadAuth() {
try {
return JSON.parse(readFileSync(getAuthPath(), 'utf8'));
} catch {
return null;
}
}
function saveAuth(data) {
writeFileSync(getAuthPath(), JSON.stringify(data, null, 2), 'utf8');
}
export function getAccessToken() {
const auth = loadAuth();
if (!auth?.access_token) return null;
return auth.access_token;
}
export function getUserId() {
const auth = loadAuth();
return auth?.user_id || null;
}
export function getUserName() {
const auth = loadAuth();
return auth?.user_name || null;
}
async function fetchUserInfo(accessToken) {
const res = await fetch(USERINFO_ENDPOINT, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
return await res.json();
}
async function registerClient() {
const res = await fetch(REGISTER_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Alpha Hub CLI',
redirect_uris: [REDIRECT_URI],
grant_types: ['authorization_code'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
}),
});
if (!res.ok) throw new Error(`Client registration failed: ${res.status}`);
return await res.json();
}
function generatePKCE() {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function openBrowser(url) {
const plat = platform();
if (plat === 'darwin') execSync(`open "${url}"`);
else if (plat === 'linux') execSync(`xdg-open "${url}"`);
else if (plat === 'win32') execSync(`start "${url}"`);
}
function waitForCallback(server) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
server.close();
reject(new Error('Login timed out after 120 seconds'));
}, 120000);
server.on('request', (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${CALLBACK_PORT}`);
if (url.pathname !== '/callback') {
res.writeHead(404);
res.end();
return;
}
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h2>Login failed</h2><p>You can close this tab.</p></body></html>');
clearTimeout(timeout);
server.close();
reject(new Error(`OAuth error: ${error}`));
return;
}
if (code) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h2>Logged in to Alpha Hub</h2><p>You can close this tab.</p></body></html>');
clearTimeout(timeout);
server.close();
resolve(code);
}
});
});
}
async function exchangeCode(code, clientId, codeVerifier) {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: clientId,
code_verifier: codeVerifier,
});
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Token exchange failed (${res.status}): ${text}`);
}
return await res.json();
}
export async function refreshAccessToken() {
const auth = loadAuth();
if (!auth?.refresh_token || !auth?.client_id) return null;
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: auth.refresh_token,
client_id: auth.client_id,
});
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) return null;
const tokens = await res.json();
saveAuth({
...auth,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token || auth.refresh_token,
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : auth.expires_at,
});
return tokens.access_token;
}
export async function login() {
const registration = await registerClient();
const clientId = registration.client_id;
const { verifier, challenge } = generatePKCE();
const state = randomBytes(16).toString('hex');
const authUrl = new URL(AUTH_ENDPOINT);
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', SCOPES);
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', state);
const server = createServer();
server.listen(CALLBACK_PORT);
process.stderr.write('Opening browser for alphaXiv login...\n');
openBrowser(authUrl.toString());
process.stderr.write(`If browser didn't open, visit:\n${authUrl.toString()}\n\n`);
process.stderr.write('Waiting for login...\n');
const code = await waitForCallback(server);
const tokens = await exchangeCode(code, clientId, verifier);
const userInfo = await fetchUserInfo(tokens.access_token);
saveAuth({
client_id: clientId,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
user_id: userInfo?.sub || null,
user_name: userInfo?.name || userInfo?.preferred_username || null,
user_email: userInfo?.email || null,
});
return { tokens, userInfo };
}
export async function getValidToken() {
let token = getAccessToken();
if (token) {
const auth = loadAuth();
if (auth?.expires_at && Date.now() > auth.expires_at - 60000) {
token = await refreshAccessToken();
}
if (token) return token;
}
return null;
}
export function isLoggedIn() {
return !!getAccessToken();
}
export function logout() {
try {
writeFileSync(getAuthPath(), '{}', 'utf8');
} catch {
}
}

20
cli/src/lib/output.js Normal file
View file

@ -0,0 +1,20 @@
export function output(data, humanFormatter, opts) {
if (opts?.json) {
console.log(JSON.stringify(data, null, 2));
} else {
humanFormatter(data);
}
}
export function info(msg) {
process.stderr.write(msg + '\n');
}
export function error(msg, opts) {
if (opts?.json) {
console.log(JSON.stringify({ error: msg }));
} else {
process.stderr.write(`Error: ${msg}\n`);
}
process.exit(1);
}

25
cli/src/lib/papers.js Normal file
View file

@ -0,0 +1,25 @@
export function normalizePaperId(input) {
const patterns = [
/arxiv\.org\/abs\/(\d+\.\d+)/,
/arxiv\.org\/pdf\/(\d+\.\d+)/,
/alphaxiv\.org\/(?:abs|overview)\/(\d+\.\d+)/,
];
for (const pattern of patterns) {
const match = input.match(pattern);
if (match) return match[1];
}
if (/^\d+\.\d+$/.test(input)) return input;
return input;
}
export function toArxivUrl(input) {
const id = normalizePaperId(input);
if (/^\d+\.\d+$/.test(id)) {
return `https://arxiv.org/abs/${id}`;
}
if (input.startsWith('http')) return input;
return `https://arxiv.org/abs/${input}`;
}

84
cli/src/mcp/server.js Normal file
View 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
View 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}`);
}
}

6
package.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "alpha-hub",
"version": "0.1.0",
"private": true,
"type": "module"
}