mirror of
https://github.com/getcompanion-ai/alpha-hub.git
synced 2026-04-15 18:01:24 +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
65
cli/src/commands/annotate.js
Normal file
65
cli/src/commands/annotate.js
Normal 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
24
cli/src/commands/ask.js
Normal 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
39
cli/src/commands/get.js
Normal 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
31
cli/src/commands/login.js
Normal 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'));
|
||||
});
|
||||
}
|
||||
37
cli/src/commands/search.js
Normal file
37
cli/src/commands/search.js
Normal 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
67
cli/src/index.js
Normal 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
110
cli/src/lib/alphaxiv.js
Normal 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;
|
||||
}
|
||||
}
|
||||
57
cli/src/lib/annotations.js
Normal file
57
cli/src/lib/annotations.js
Normal 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
245
cli/src/lib/auth.js
Normal 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
20
cli/src/lib/output.js
Normal 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
25
cli/src/lib/papers.js
Normal 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
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