@companion-ai/alpha-hub 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Companion AI
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,30 @@
1
+ # Alpha Hub
2
+
3
+ Unofficial alphaXiv-powered CLI and library for research agents.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @companion-ai/alpha-hub
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ alpha login
15
+ alpha search "attention mechanism"
16
+ alpha get 1706.03762
17
+ alpha ask 1706.03762 "What datasets were used for evaluation?"
18
+ alpha code https://github.com/openai/gpt-2 /
19
+ ```
20
+
21
+ ## Package Exports
22
+
23
+ This package exposes:
24
+
25
+ - `alpha` CLI
26
+ - `alpha-mcp` CLI
27
+ - library helpers from `@companion-ai/alpha-hub/lib`
28
+
29
+ Repository:
30
+ https://github.com/getcompanion-ai/alpha-hub
package/bin/alpha ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/index.js';
package/bin/alpha-mcp ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/mcp/server.js';
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@companion-ai/alpha-hub",
3
+ "version": "0.1.0",
4
+ "description": "Unofficial alphaXiv-powered CLI and library for research agents",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/getcompanion-ai/alpha-hub.git",
9
+ "directory": "cli"
10
+ },
11
+ "homepage": "https://github.com/getcompanion-ai/alpha-hub#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/getcompanion-ai/alpha-hub/issues"
14
+ },
15
+ "exports": {
16
+ ".": "./src/index.js",
17
+ "./lib": {
18
+ "types": "./src/lib/index.d.ts",
19
+ "default": "./src/lib/index.js"
20
+ },
21
+ "./lib/alphaxiv": "./src/lib/alphaxiv.js",
22
+ "./lib/annotations": "./src/lib/annotations.js",
23
+ "./lib/papers": "./src/lib/papers.js"
24
+ },
25
+ "bin": {
26
+ "alpha": "./bin/alpha",
27
+ "alpha-mcp": "./bin/alpha-mcp"
28
+ },
29
+ "files": [
30
+ "README.md",
31
+ "LICENSE",
32
+ "bin/",
33
+ "src/"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ },
38
+ "keywords": [
39
+ "research",
40
+ "papers",
41
+ "annotations",
42
+ "alphaxiv",
43
+ "mcp",
44
+ "cli"
45
+ ],
46
+ "license": "MIT",
47
+ "dependencies": {
48
+ "@modelcontextprotocol/sdk": "^1.27.1",
49
+ "chalk": "^5.3.0",
50
+ "commander": "^12.0.0",
51
+ "zod": "^3.23.0"
52
+ }
53
+ }
@@ -0,0 +1,65 @@
1
+ import chalk from 'chalk';
2
+ import { writeAnnotation, readAnnotation, clearAnnotation, listAnnotations } from '../lib/annotations.js';
3
+ import { normalizePaperId } from '../lib/papers.js';
4
+ import { output, error } from '../lib/output.js';
5
+
6
+ function formatList(annotations) {
7
+ if (annotations.length === 0) {
8
+ console.log(chalk.dim('No annotations.'));
9
+ return;
10
+ }
11
+ annotations.forEach(a => {
12
+ console.log(`${chalk.bold(a.id)} ${chalk.dim(`(${a.updatedAt})`)}`);
13
+ console.log(` ${a.note}`);
14
+ console.log();
15
+ });
16
+ }
17
+
18
+ export function registerAnnotateCommand(program) {
19
+ program
20
+ .command('annotate [paper-id] [note]')
21
+ .description('Read, write, or list local annotations')
22
+ .option('--clear', 'Remove annotation for this paper')
23
+ .option('--list', 'List all annotations')
24
+ .action(async (paperId, note, cmdOpts) => {
25
+ const opts = { ...program.opts(), ...cmdOpts };
26
+
27
+ if (opts.list) {
28
+ const all = listAnnotations();
29
+ output(all, formatList, opts);
30
+ return;
31
+ }
32
+
33
+ if (!paperId) {
34
+ error('Provide a paper ID, or use --list', opts);
35
+ }
36
+
37
+ const id = normalizePaperId(paperId);
38
+
39
+ if (opts.clear) {
40
+ const removed = clearAnnotation(id);
41
+ if (removed) {
42
+ console.log(chalk.green(`Cleared annotation for ${id}`));
43
+ } else {
44
+ console.log(chalk.dim(`No annotation for ${id}`));
45
+ }
46
+ return;
47
+ }
48
+
49
+ if (note) {
50
+ const saved = writeAnnotation(id, note);
51
+ output(saved, () => console.log(chalk.green(`Annotation saved for ${id}`)), opts);
52
+ return;
53
+ }
54
+
55
+ const existing = readAnnotation(id);
56
+ if (existing) {
57
+ output(existing, () => {
58
+ console.log(`${chalk.bold(existing.id)} ${chalk.dim(`(${existing.updatedAt})`)}`);
59
+ console.log(` ${existing.note}`);
60
+ }, opts);
61
+ } else {
62
+ console.log(chalk.dim(`No annotation for ${id}`));
63
+ }
64
+ });
65
+ }
@@ -0,0 +1,24 @@
1
+ import { answerPdfQuery, disconnect } from '../lib/alphaxiv.js';
2
+ import { output, error } from '../lib/output.js';
3
+ import { toArxivUrl } from '../lib/papers.js';
4
+
5
+ function formatAnswer(data) {
6
+ console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
7
+ }
8
+
9
+ export function registerAskCommand(program) {
10
+ program
11
+ .command('ask <url> <question>')
12
+ .description('Ask a question about a paper (arXiv/alphaXiv URL or ID)')
13
+ .action(async (url, question, cmdOpts) => {
14
+ const opts = { ...program.opts(), ...cmdOpts };
15
+ try {
16
+ const answer = await answerPdfQuery(toArxivUrl(url), question);
17
+ output(answer, formatAnswer, opts);
18
+ } catch (err) {
19
+ error(err.message, opts);
20
+ } finally {
21
+ await disconnect();
22
+ }
23
+ });
24
+ }
@@ -0,0 +1,24 @@
1
+ import { readGithubRepo, disconnect } from '../lib/alphaxiv.js';
2
+ import { output, error } from '../lib/output.js';
3
+
4
+ function formatResult(data) {
5
+ const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
6
+ console.log(text);
7
+ }
8
+
9
+ export function registerCodeCommand(program) {
10
+ program
11
+ .command('code <github-url> [path]')
12
+ .description("Read files from a paper's GitHub repository")
13
+ .action(async (githubUrl, path, cmdOpts) => {
14
+ const opts = { ...program.opts(), ...cmdOpts };
15
+ try {
16
+ const result = await readGithubRepo(githubUrl, path || '/');
17
+ output(result, formatResult, opts);
18
+ } catch (err) {
19
+ error(err.message, opts);
20
+ } finally {
21
+ await disconnect();
22
+ }
23
+ });
24
+ }
@@ -0,0 +1,39 @@
1
+ import chalk from 'chalk';
2
+ import { getPaperContent, disconnect } from '../lib/alphaxiv.js';
3
+ import { readAnnotation } from '../lib/annotations.js';
4
+ import { output, error } from '../lib/output.js';
5
+ import { normalizePaperId, toArxivUrl } from '../lib/papers.js';
6
+
7
+ function formatPaper({ content, annotation }) {
8
+ console.log(typeof content === 'string' ? content : JSON.stringify(content, null, 2));
9
+
10
+ if (annotation) {
11
+ console.log();
12
+ console.log(chalk.dim('---'));
13
+ console.log(chalk.dim(`[Note — ${annotation.updatedAt}]`));
14
+ console.log(annotation.note);
15
+ }
16
+ }
17
+
18
+ export function registerGetCommand(program) {
19
+ program
20
+ .command('get <url>')
21
+ .description('Get paper content + local annotation (arXiv/alphaXiv URL or ID)')
22
+ .option('--full-text', 'Get raw extracted text instead of AI report')
23
+ .action(async (url, cmdOpts) => {
24
+ const opts = { ...program.opts(), ...cmdOpts };
25
+ try {
26
+ const paperId = normalizePaperId(url);
27
+ const arxivUrl = toArxivUrl(url);
28
+
29
+ const content = await getPaperContent(arxivUrl, { fullText: !!opts.fullText });
30
+ const annotation = readAnnotation(paperId);
31
+
32
+ output({ content, annotation }, formatPaper, opts);
33
+ } catch (err) {
34
+ error(err.message, opts);
35
+ } finally {
36
+ await disconnect();
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,31 @@
1
+ import chalk from 'chalk';
2
+ import { login, isLoggedIn, logout } from '../lib/auth.js';
3
+
4
+ export function registerLoginCommand(program) {
5
+ program
6
+ .command('login')
7
+ .description('Log in to alphaXiv (opens browser)')
8
+ .action(async () => {
9
+ try {
10
+ if (isLoggedIn()) {
11
+ process.stderr.write(chalk.dim('Already logged in. Use `alpha logout` to sign out first.\n'));
12
+ }
13
+ const { userInfo } = await login();
14
+ const name = userInfo?.name || userInfo?.email || 'unknown';
15
+ console.log(chalk.green(`Logged in to alphaXiv as ${name}`));
16
+ } catch (err) {
17
+ process.stderr.write(`${chalk.red('Login failed:')} ${err.message}\n`);
18
+ process.exit(1);
19
+ }
20
+ });
21
+ }
22
+
23
+ export function registerLogoutCommand(program) {
24
+ program
25
+ .command('logout')
26
+ .description('Log out of alphaXiv')
27
+ .action(() => {
28
+ logout();
29
+ console.log(chalk.green('Logged out'));
30
+ });
31
+ }
@@ -0,0 +1,46 @@
1
+ import chalk from 'chalk';
2
+ import { searchByEmbedding, searchByKeyword, agenticSearch, disconnect } from '../lib/alphaxiv.js';
3
+ import { output, error } from '../lib/output.js';
4
+
5
+ function formatResults(data) {
6
+ const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
7
+ console.log(text);
8
+ }
9
+
10
+ export function registerSearchCommand(program) {
11
+ program
12
+ .command('search <query>')
13
+ .description('Search papers via alphaXiv (semantic, keyword, both, agentic, or all)')
14
+ .option('-m, --mode <mode>', 'Search mode: semantic, keyword, both, agentic, all', 'semantic')
15
+ .action(async (query, cmdOpts) => {
16
+ const opts = { ...program.opts(), ...cmdOpts };
17
+ try {
18
+ let results;
19
+ if (opts.mode === 'keyword') {
20
+ results = await searchByKeyword(query);
21
+ } else if (opts.mode === 'agentic') {
22
+ results = await agenticSearch(query);
23
+ } else if (opts.mode === 'both') {
24
+ const [semantic, keyword] = await Promise.all([
25
+ searchByEmbedding(query),
26
+ searchByKeyword(query),
27
+ ]);
28
+ results = { semantic, keyword };
29
+ } else if (opts.mode === 'all') {
30
+ const [semantic, keyword, agentic] = await Promise.all([
31
+ searchByEmbedding(query),
32
+ searchByKeyword(query),
33
+ agenticSearch(query),
34
+ ]);
35
+ results = { semantic, keyword, agentic };
36
+ } else {
37
+ results = await searchByEmbedding(query);
38
+ }
39
+ output(results, formatResults, opts);
40
+ } catch (err) {
41
+ error(err.message, opts);
42
+ } finally {
43
+ await disconnect();
44
+ }
45
+ });
46
+ }
package/src/index.js ADDED
@@ -0,0 +1,73 @@
1
+ import chalk from 'chalk';
2
+ import { Command } from 'commander';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+ import { registerSearchCommand } from './commands/search.js';
7
+ import { registerGetCommand } from './commands/get.js';
8
+ import { registerAskCommand } from './commands/ask.js';
9
+ import { registerAnnotateCommand } from './commands/annotate.js';
10
+ import { registerCodeCommand } from './commands/code.js';
11
+ import { registerLoginCommand, registerLogoutCommand } from './commands/login.js';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
15
+
16
+ function printUsage() {
17
+ console.log(`
18
+ ${chalk.bold('alpha')} — Alpha Hub CLI v${pkg.version}
19
+ Search papers and annotate what you learn. Powered by alphaXiv.
20
+
21
+ ${chalk.bold.underline('Usage')}
22
+
23
+ ${chalk.dim('$')} alpha search "transformer attention mechanisms" ${chalk.dim('# semantic search')}
24
+ ${chalk.dim('$')} alpha search "LoRA" --mode keyword ${chalk.dim('# keyword search')}
25
+ ${chalk.dim('$')} alpha search "hallucination in LLMs" --mode agentic ${chalk.dim('# agentic retrieval')}
26
+ ${chalk.dim('$')} alpha search "RAG for QA" --mode all ${chalk.dim('# semantic + keyword + agentic')}
27
+ ${chalk.dim('$')} alpha get 1706.03762 ${chalk.dim('# paper content + annotation')}
28
+ ${chalk.dim('$')} alpha get https://arxiv.org/abs/2106.09685 ${chalk.dim('# by URL')}
29
+ ${chalk.dim('$')} alpha ask 1706.03762 "How does attention work?" ${chalk.dim('# ask about a paper')}
30
+ ${chalk.dim('$')} alpha code https://github.com/openai/gpt-2 / ${chalk.dim('# inspect repo structure')}
31
+ ${chalk.dim('$')} alpha annotate 1706.03762 "key insight" ${chalk.dim('# save a note')}
32
+ ${chalk.dim('$')} alpha annotate --list ${chalk.dim('# see all notes')}
33
+
34
+ ${chalk.bold.underline('Commands')}
35
+
36
+ ${chalk.bold('login')} Log in to alphaXiv (opens browser)
37
+ ${chalk.bold('logout')} Log out
38
+ ${chalk.bold('search')} <query> Search papers (semantic, keyword, both, agentic, or all)
39
+ ${chalk.bold('get')} <url|arxiv-id> Paper content + local annotation
40
+ ${chalk.bold('ask')} <url|arxiv-id> <question> Ask a question about a paper
41
+ ${chalk.bold('code')} <github-url> [path] Read files from a paper repository
42
+ ${chalk.bold('annotate')} [paper-id] [note] Save a note — appears on future fetches
43
+ ${chalk.bold('annotate')} <paper-id> --clear Remove a note
44
+ ${chalk.bold('annotate')} --list List all notes
45
+
46
+ ${chalk.bold.underline('Flags')}
47
+
48
+ --json JSON output (for agents and piping)
49
+ -m, --mode <mode> Search mode: semantic, keyword, both, agentic, all (default: semantic)
50
+ --full-text Get raw text instead of AI report (for get)
51
+ `);
52
+ }
53
+
54
+ const program = new Command();
55
+
56
+ program
57
+ .name('alpha')
58
+ .description('Alpha Hub - search papers and annotate what you learn')
59
+ .version(pkg.version, '-V, --cli-version')
60
+ .option('--json', 'Output as JSON (machine-readable)')
61
+ .action(() => {
62
+ printUsage();
63
+ });
64
+
65
+ registerLoginCommand(program);
66
+ registerLogoutCommand(program);
67
+ registerSearchCommand(program);
68
+ registerGetCommand(program);
69
+ registerAskCommand(program);
70
+ registerCodeCommand(program);
71
+ registerAnnotateCommand(program);
72
+
73
+ program.parse();
@@ -0,0 +1,120 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { getValidToken, refreshAccessToken } from './auth.js';
4
+
5
+ const ALPHAXIV_MCP_URL = 'https://api.alphaxiv.org/mcp/v1';
6
+
7
+ let _client = null;
8
+ let _connected = false;
9
+
10
+ async function getClient() {
11
+ if (_client && _connected) return _client;
12
+
13
+ const token = await getValidToken();
14
+ if (!token) {
15
+ throw new Error('Not logged in. Run `alpha login` first.');
16
+ }
17
+
18
+ _client = new Client({ name: 'alpha', version: '0.1.0' });
19
+
20
+ _client.onerror = (err) => {
21
+ process.stderr.write(`[alpha] alphaXiv MCP error: ${err.message || err}\n`);
22
+ };
23
+
24
+ const transport = new StreamableHTTPClientTransport(new URL(ALPHAXIV_MCP_URL), {
25
+ requestInit: {
26
+ headers: {
27
+ Authorization: `Bearer ${token}`,
28
+ },
29
+ },
30
+ });
31
+
32
+ await _client.connect(transport);
33
+ _connected = true;
34
+
35
+ return _client;
36
+ }
37
+
38
+ async function callTool(name, args) {
39
+ let client;
40
+ try {
41
+ client = await getClient();
42
+ } catch (err) {
43
+ if (err.message?.includes('401') || err.message?.includes('Unauthorized')) {
44
+ const newToken = await refreshAccessToken();
45
+ if (newToken) {
46
+ _client = null;
47
+ _connected = false;
48
+ client = await getClient();
49
+ } else {
50
+ throw new Error('Session expired. Run `alpha login` to re-authenticate.');
51
+ }
52
+ } else {
53
+ throw err;
54
+ }
55
+ }
56
+
57
+ const result = await client.callTool({ name, arguments: args });
58
+
59
+ if (result.isError) {
60
+ const text = result.content?.[0]?.text || 'Unknown error';
61
+ throw new Error(text);
62
+ }
63
+
64
+ const text = result.content?.[0]?.text;
65
+ if (!text) return result.content;
66
+
67
+ try {
68
+ return JSON.parse(text);
69
+ } catch {
70
+ return text;
71
+ }
72
+ }
73
+
74
+ export async function searchByEmbedding(query) {
75
+ return await callTool('embedding_similarity_search', { query });
76
+ }
77
+
78
+ export async function searchByKeyword(query) {
79
+ return await callTool('full_text_papers_search', { query });
80
+ }
81
+
82
+ export async function agenticSearch(query) {
83
+ return await callTool('agentic_paper_retrieval', { query });
84
+ }
85
+
86
+ export async function searchAll(query) {
87
+ const [semantic, keyword, agentic] = await Promise.all([
88
+ searchByEmbedding(query),
89
+ searchByKeyword(query),
90
+ agenticSearch(query),
91
+ ]);
92
+
93
+ return { semantic, keyword, agentic };
94
+ }
95
+
96
+ export async function getPaperContent(url, { fullText = false } = {}) {
97
+ const args = { url };
98
+ if (fullText) args.fullText = true;
99
+ return await callTool('get_paper_content', args);
100
+ }
101
+
102
+ export async function answerPdfQuery(url, query) {
103
+ return await callTool('answer_pdf_queries', { url, query });
104
+ }
105
+
106
+ export async function readGithubRepo(githubUrl, path = '/') {
107
+ return await callTool('read_files_from_github_repository', { githubUrl, path });
108
+ }
109
+
110
+ export async function disconnect() {
111
+ if (_client) {
112
+ _client.onerror = () => {};
113
+ try {
114
+ await _client.close();
115
+ } catch {
116
+ }
117
+ _client = null;
118
+ _connected = false;
119
+ }
120
+ }
@@ -0,0 +1,57 @@
1
+ import { readFileSync, writeFileSync, unlinkSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ function getAnnotationsDir() {
6
+ const dir = join(homedir(), '.ahub', 'annotations');
7
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
8
+ return dir;
9
+ }
10
+
11
+ function safeFilename(id) {
12
+ return id.replace(/\//g, '--') + '.json';
13
+ }
14
+
15
+ export function writeAnnotation(id, note) {
16
+ const filePath = join(getAnnotationsDir(), safeFilename(id));
17
+ const data = {
18
+ id,
19
+ note,
20
+ updatedAt: new Date().toISOString(),
21
+ };
22
+ writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
23
+ return data;
24
+ }
25
+
26
+ export function readAnnotation(id) {
27
+ try {
28
+ const filePath = join(getAnnotationsDir(), safeFilename(id));
29
+ return JSON.parse(readFileSync(filePath, 'utf8'));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export function clearAnnotation(id) {
36
+ try {
37
+ const filePath = join(getAnnotationsDir(), safeFilename(id));
38
+ unlinkSync(filePath);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ export function listAnnotations() {
46
+ const dir = getAnnotationsDir();
47
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
48
+ const annotations = [];
49
+ for (const file of files) {
50
+ try {
51
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf8'));
52
+ if (data.id && data.note) annotations.push(data);
53
+ } catch {
54
+ }
55
+ }
56
+ return annotations;
57
+ }
@@ -0,0 +1,245 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { execSync } from 'node:child_process';
7
+ import { platform } from 'node:os';
8
+
9
+ const CLERK_ISSUER = 'https://clerk.alphaxiv.org';
10
+ const AUTH_ENDPOINT = `${CLERK_ISSUER}/oauth/authorize`;
11
+ const TOKEN_ENDPOINT = `${CLERK_ISSUER}/oauth/token`;
12
+ const REGISTER_ENDPOINT = `${CLERK_ISSUER}/oauth/register`;
13
+ const CALLBACK_PORT = 9876;
14
+ const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
15
+ const USERINFO_ENDPOINT = `${CLERK_ISSUER}/oauth/userinfo`;
16
+ const SCOPES = 'profile email offline_access';
17
+
18
+ function getAuthPath() {
19
+ const dir = join(homedir(), '.ahub');
20
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
21
+ return join(dir, 'auth.json');
22
+ }
23
+
24
+ function loadAuth() {
25
+ try {
26
+ return JSON.parse(readFileSync(getAuthPath(), 'utf8'));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function saveAuth(data) {
33
+ writeFileSync(getAuthPath(), JSON.stringify(data, null, 2), 'utf8');
34
+ }
35
+
36
+ export function getAccessToken() {
37
+ const auth = loadAuth();
38
+ if (!auth?.access_token) return null;
39
+ return auth.access_token;
40
+ }
41
+
42
+ export function getUserId() {
43
+ const auth = loadAuth();
44
+ return auth?.user_id || null;
45
+ }
46
+
47
+ export function getUserName() {
48
+ const auth = loadAuth();
49
+ return auth?.user_name || null;
50
+ }
51
+
52
+ async function fetchUserInfo(accessToken) {
53
+ const res = await fetch(USERINFO_ENDPOINT, {
54
+ headers: { Authorization: `Bearer ${accessToken}` },
55
+ });
56
+ if (!res.ok) return null;
57
+ return await res.json();
58
+ }
59
+
60
+ async function registerClient() {
61
+ const res = await fetch(REGISTER_ENDPOINT, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({
65
+ client_name: 'Alpha Hub CLI',
66
+ redirect_uris: [REDIRECT_URI],
67
+ grant_types: ['authorization_code'],
68
+ response_types: ['code'],
69
+ token_endpoint_auth_method: 'none',
70
+ }),
71
+ });
72
+
73
+ if (!res.ok) throw new Error(`Client registration failed: ${res.status}`);
74
+ return await res.json();
75
+ }
76
+
77
+ function generatePKCE() {
78
+ const verifier = randomBytes(32).toString('base64url');
79
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
80
+ return { verifier, challenge };
81
+ }
82
+
83
+ function openBrowser(url) {
84
+ const plat = platform();
85
+ if (plat === 'darwin') execSync(`open "${url}"`);
86
+ else if (plat === 'linux') execSync(`xdg-open "${url}"`);
87
+ else if (plat === 'win32') execSync(`start "${url}"`);
88
+ }
89
+
90
+ function waitForCallback(server) {
91
+ return new Promise((resolve, reject) => {
92
+ const timeout = setTimeout(() => {
93
+ server.close();
94
+ reject(new Error('Login timed out after 120 seconds'));
95
+ }, 120000);
96
+
97
+ server.on('request', (req, res) => {
98
+ const url = new URL(req.url, `http://127.0.0.1:${CALLBACK_PORT}`);
99
+
100
+ if (url.pathname !== '/callback') {
101
+ res.writeHead(404);
102
+ res.end();
103
+ return;
104
+ }
105
+
106
+ const code = url.searchParams.get('code');
107
+ const error = url.searchParams.get('error');
108
+
109
+ if (error) {
110
+ res.writeHead(200, { 'Content-Type': 'text/html' });
111
+ res.end('<html><body><h2>Login failed</h2><p>You can close this tab.</p></body></html>');
112
+ clearTimeout(timeout);
113
+ server.close();
114
+ reject(new Error(`OAuth error: ${error}`));
115
+ return;
116
+ }
117
+
118
+ if (code) {
119
+ res.writeHead(200, { 'Content-Type': 'text/html' });
120
+ res.end('<html><body><h2>Logged in to Alpha Hub</h2><p>You can close this tab.</p></body></html>');
121
+ clearTimeout(timeout);
122
+ server.close();
123
+ resolve(code);
124
+ }
125
+ });
126
+ });
127
+ }
128
+
129
+ async function exchangeCode(code, clientId, codeVerifier) {
130
+ const body = new URLSearchParams({
131
+ grant_type: 'authorization_code',
132
+ code,
133
+ redirect_uri: REDIRECT_URI,
134
+ client_id: clientId,
135
+ code_verifier: codeVerifier,
136
+ });
137
+
138
+ const res = await fetch(TOKEN_ENDPOINT, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
141
+ body: body.toString(),
142
+ });
143
+
144
+ if (!res.ok) {
145
+ const text = await res.text();
146
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
147
+ }
148
+
149
+ return await res.json();
150
+ }
151
+
152
+ export async function refreshAccessToken() {
153
+ const auth = loadAuth();
154
+ if (!auth?.refresh_token || !auth?.client_id) return null;
155
+
156
+ const body = new URLSearchParams({
157
+ grant_type: 'refresh_token',
158
+ refresh_token: auth.refresh_token,
159
+ client_id: auth.client_id,
160
+ });
161
+
162
+ const res = await fetch(TOKEN_ENDPOINT, {
163
+ method: 'POST',
164
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
165
+ body: body.toString(),
166
+ });
167
+
168
+ if (!res.ok) return null;
169
+
170
+ const tokens = await res.json();
171
+ saveAuth({
172
+ ...auth,
173
+ access_token: tokens.access_token,
174
+ refresh_token: tokens.refresh_token || auth.refresh_token,
175
+ expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : auth.expires_at,
176
+ });
177
+
178
+ return tokens.access_token;
179
+ }
180
+
181
+ export async function login() {
182
+ const registration = await registerClient();
183
+ const clientId = registration.client_id;
184
+ const { verifier, challenge } = generatePKCE();
185
+
186
+ const state = randomBytes(16).toString('hex');
187
+
188
+ const authUrl = new URL(AUTH_ENDPOINT);
189
+ authUrl.searchParams.set('client_id', clientId);
190
+ authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
191
+ authUrl.searchParams.set('response_type', 'code');
192
+ authUrl.searchParams.set('scope', SCOPES);
193
+ authUrl.searchParams.set('code_challenge', challenge);
194
+ authUrl.searchParams.set('code_challenge_method', 'S256');
195
+ authUrl.searchParams.set('state', state);
196
+
197
+ const server = createServer();
198
+ server.listen(CALLBACK_PORT);
199
+
200
+ process.stderr.write('Opening browser for alphaXiv login...\n');
201
+ openBrowser(authUrl.toString());
202
+ process.stderr.write(`If browser didn't open, visit:\n${authUrl.toString()}\n\n`);
203
+ process.stderr.write('Waiting for login...\n');
204
+
205
+ const code = await waitForCallback(server);
206
+
207
+ const tokens = await exchangeCode(code, clientId, verifier);
208
+
209
+ const userInfo = await fetchUserInfo(tokens.access_token);
210
+
211
+ saveAuth({
212
+ client_id: clientId,
213
+ access_token: tokens.access_token,
214
+ refresh_token: tokens.refresh_token,
215
+ expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
216
+ user_id: userInfo?.sub || null,
217
+ user_name: userInfo?.name || userInfo?.preferred_username || null,
218
+ user_email: userInfo?.email || null,
219
+ });
220
+
221
+ return { tokens, userInfo };
222
+ }
223
+
224
+ export async function getValidToken() {
225
+ let token = getAccessToken();
226
+ if (token) {
227
+ const auth = loadAuth();
228
+ if (auth?.expires_at && Date.now() > auth.expires_at - 60000) {
229
+ token = await refreshAccessToken();
230
+ }
231
+ if (token) return token;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ export function isLoggedIn() {
237
+ return !!getAccessToken();
238
+ }
239
+
240
+ export function logout() {
241
+ try {
242
+ writeFileSync(getAuthPath(), '{}', 'utf8');
243
+ } catch {
244
+ }
245
+ }
@@ -0,0 +1,67 @@
1
+ export declare function disconnect(): Promise<void>;
2
+ export declare function normalizePaperId(input: string): string;
3
+
4
+ export declare function searchAll(query: string): Promise<unknown>;
5
+ export declare function searchByEmbedding(query: string): Promise<unknown>;
6
+ export declare function searchByKeyword(query: string): Promise<unknown>;
7
+ export declare function agenticSearch(query: string): Promise<unknown>;
8
+
9
+ export declare function searchPapers(
10
+ query: string,
11
+ mode?: "semantic" | "keyword" | "both" | "agentic" | "all" | string,
12
+ ): Promise<unknown>;
13
+
14
+ export declare function getPaper(
15
+ identifier: string,
16
+ options?: { fullText?: boolean },
17
+ ): Promise<{
18
+ paperId: string;
19
+ url: string;
20
+ content: unknown;
21
+ annotation: unknown;
22
+ }>;
23
+
24
+ export declare function askPaper(
25
+ identifier: string,
26
+ question: string,
27
+ ): Promise<{
28
+ paperId: string;
29
+ url: string;
30
+ question: string;
31
+ answer: unknown;
32
+ }>;
33
+
34
+ export declare function annotatePaper(
35
+ identifier: string,
36
+ note: string,
37
+ ): Promise<{
38
+ status: "saved";
39
+ annotation: unknown;
40
+ }>;
41
+
42
+ export declare function clearPaperAnnotation(
43
+ identifier: string,
44
+ ): Promise<{
45
+ status: "cleared" | "not_found";
46
+ paperId: string;
47
+ }>;
48
+
49
+ export declare function getPaperAnnotation(
50
+ identifier: string,
51
+ ): Promise<{
52
+ status: "found" | "no_annotation";
53
+ annotation?: unknown;
54
+ paperId?: string;
55
+ }>;
56
+
57
+ export declare function listPaperAnnotations(): Promise<{
58
+ total: number;
59
+ annotations: unknown[];
60
+ }>;
61
+
62
+ export declare function readPaperCode(githubUrl: string, path?: string): Promise<unknown>;
63
+
64
+ export declare function readAnnotation(id: string): unknown;
65
+ export declare function writeAnnotation(id: string, note: string): unknown;
66
+ export declare function clearAnnotation(id: string): boolean;
67
+ export declare function listAnnotations(): unknown[];
@@ -0,0 +1,87 @@
1
+ import {
2
+ agenticSearch,
3
+ answerPdfQuery,
4
+ disconnect,
5
+ getPaperContent,
6
+ readGithubRepo,
7
+ searchAll,
8
+ searchByEmbedding,
9
+ searchByKeyword,
10
+ } from './alphaxiv.js';
11
+ import {
12
+ clearAnnotation,
13
+ listAnnotations,
14
+ readAnnotation,
15
+ writeAnnotation,
16
+ } from './annotations.js';
17
+ import { normalizePaperId, toArxivUrl } from './papers.js';
18
+
19
+ export {
20
+ disconnect,
21
+ normalizePaperId,
22
+ searchAll,
23
+ searchByEmbedding,
24
+ searchByKeyword,
25
+ agenticSearch,
26
+ readAnnotation,
27
+ writeAnnotation,
28
+ clearAnnotation,
29
+ listAnnotations,
30
+ readGithubRepo,
31
+ };
32
+
33
+ export async function searchPapers(query, mode = 'semantic') {
34
+ if (mode === 'keyword') return searchByKeyword(query);
35
+ if (mode === 'agentic') return agenticSearch(query);
36
+ if (mode === 'both') {
37
+ const [semantic, keyword] = await Promise.all([
38
+ searchByEmbedding(query),
39
+ searchByKeyword(query),
40
+ ]);
41
+ return { semantic, keyword };
42
+ }
43
+ if (mode === 'all') return searchAll(query);
44
+ return searchByEmbedding(query);
45
+ }
46
+
47
+ export async function getPaper(identifier, options = {}) {
48
+ const paperId = normalizePaperId(identifier);
49
+ const url = toArxivUrl(identifier);
50
+ const content = await getPaperContent(url, { fullText: Boolean(options.fullText) });
51
+ const annotation = readAnnotation(paperId);
52
+ return { paperId, url, content, annotation };
53
+ }
54
+
55
+ export async function askPaper(identifier, question) {
56
+ const paperId = normalizePaperId(identifier);
57
+ const url = toArxivUrl(identifier);
58
+ const answer = await answerPdfQuery(url, question);
59
+ return { paperId, url, question, answer };
60
+ }
61
+
62
+ export async function annotatePaper(identifier, note) {
63
+ const paperId = normalizePaperId(identifier);
64
+ const annotation = writeAnnotation(paperId, note);
65
+ return { status: 'saved', annotation };
66
+ }
67
+
68
+ export async function clearPaperAnnotation(identifier) {
69
+ const paperId = normalizePaperId(identifier);
70
+ const cleared = clearAnnotation(paperId);
71
+ return { status: cleared ? 'cleared' : 'not_found', paperId };
72
+ }
73
+
74
+ export async function getPaperAnnotation(identifier) {
75
+ const paperId = normalizePaperId(identifier);
76
+ const annotation = readAnnotation(paperId);
77
+ return annotation ? { status: 'found', annotation } : { status: 'no_annotation', paperId };
78
+ }
79
+
80
+ export async function listPaperAnnotations() {
81
+ const annotations = listAnnotations();
82
+ return { total: annotations.length, annotations };
83
+ }
84
+
85
+ export async function readPaperCode(githubUrl, path = '/') {
86
+ return readGithubRepo(githubUrl, path);
87
+ }
@@ -0,0 +1,20 @@
1
+ export function output(data, humanFormatter, opts) {
2
+ if (opts?.json) {
3
+ console.log(JSON.stringify(data, null, 2));
4
+ } else {
5
+ humanFormatter(data);
6
+ }
7
+ }
8
+
9
+ export function info(msg) {
10
+ process.stderr.write(msg + '\n');
11
+ }
12
+
13
+ export function error(msg, opts) {
14
+ if (opts?.json) {
15
+ console.log(JSON.stringify({ error: msg }));
16
+ } else {
17
+ process.stderr.write(`Error: ${msg}\n`);
18
+ }
19
+ process.exit(1);
20
+ }
@@ -0,0 +1,25 @@
1
+ export function normalizePaperId(input) {
2
+ const patterns = [
3
+ /arxiv\.org\/abs\/(\d+\.\d+)/,
4
+ /arxiv\.org\/pdf\/(\d+\.\d+)/,
5
+ /alphaxiv\.org\/(?:abs|overview)\/(\d+\.\d+)/,
6
+ ];
7
+
8
+ for (const pattern of patterns) {
9
+ const match = input.match(pattern);
10
+ if (match) return match[1];
11
+ }
12
+
13
+ if (/^\d+\.\d+$/.test(input)) return input;
14
+
15
+ return input;
16
+ }
17
+
18
+ export function toArxivUrl(input) {
19
+ const id = normalizePaperId(input);
20
+ if (/^\d+\.\d+$/.test(id)) {
21
+ return `https://arxiv.org/abs/${id}`;
22
+ }
23
+ if (input.startsWith('http')) return input;
24
+ return `https://arxiv.org/abs/${input}`;
25
+ }
@@ -0,0 +1,84 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { z } from 'zod';
7
+ import { handleSearch, handleGet, handleAsk, handleAnnotate, handleCode } from './tools.js';
8
+
9
+ const _stderr = process.stderr;
10
+ console.log = (...args) => _stderr.write(args.join(' ') + '\n');
11
+ console.warn = (...args) => _stderr.write('[warn] ' + args.join(' ') + '\n');
12
+ console.info = (...args) => _stderr.write('[info] ' + args.join(' ') + '\n');
13
+ console.debug = (...args) => _stderr.write('[debug] ' + args.join(' ') + '\n');
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
17
+
18
+ const server = new McpServer({
19
+ name: 'alpha',
20
+ version: pkg.version,
21
+ });
22
+
23
+ server.tool(
24
+ 'alpha_search',
25
+ 'Search research papers via alphaXiv. Supports semantic (embedding), keyword, and agentic search modes.',
26
+ {
27
+ query: z.string().describe('Search query — use 2-3 sentences for semantic mode, keywords for keyword mode'),
28
+ mode: z.enum(['semantic', 'keyword', 'agentic']).optional().describe('Search mode (default: semantic)'),
29
+ },
30
+ async (args) => handleSearch(args),
31
+ );
32
+
33
+ server.tool(
34
+ 'alpha_get',
35
+ 'Get paper content and local annotation. Accepts arXiv URL, alphaXiv URL, or arXiv ID.',
36
+ {
37
+ url: z.string().describe('arXiv/alphaXiv URL or arXiv ID (e.g. "2106.09685")'),
38
+ full_text: z.boolean().optional().describe('Get raw text instead of AI-generated report (default: false)'),
39
+ },
40
+ async (args) => handleGet(args),
41
+ );
42
+
43
+ server.tool(
44
+ 'alpha_ask',
45
+ 'Ask a question about a specific paper. Uses AI to analyze the PDF and answer.',
46
+ {
47
+ url: z.string().describe('arXiv/alphaXiv URL or arXiv ID'),
48
+ question: z.string().describe('Question about the paper'),
49
+ },
50
+ async (args) => handleAsk(args),
51
+ );
52
+
53
+ server.tool(
54
+ 'alpha_annotate',
55
+ 'Read, write, clear, or list local annotations on papers. Annotations persist across sessions and appear on future fetches.',
56
+ {
57
+ id: z.string().optional().describe('Paper ID (arXiv ID or URL). Required unless using list mode.'),
58
+ note: z.string().optional().describe('Annotation text to save. Omit to read existing.'),
59
+ clear: z.boolean().optional().describe('Remove annotation for this paper'),
60
+ list: z.boolean().optional().describe('List all annotations'),
61
+ },
62
+ async (args) => handleAnnotate(args),
63
+ );
64
+
65
+ server.tool(
66
+ 'alpha_code',
67
+ "Read files from a paper's GitHub repository. Use path '/' for repo overview.",
68
+ {
69
+ github_url: z.string().describe('GitHub repository URL'),
70
+ path: z.string().optional().describe("File or directory path (default: '/')"),
71
+ },
72
+ async (args) => handleCode(args),
73
+ );
74
+
75
+ process.on('uncaughtException', (err) => {
76
+ _stderr.write(`[alpha-mcp] Uncaught exception: ${err.message}\n`);
77
+ });
78
+ process.on('unhandledRejection', (reason) => {
79
+ _stderr.write(`[alpha-mcp] Unhandled rejection: ${reason}\n`);
80
+ });
81
+
82
+ const transport = new StdioServerTransport();
83
+ await server.connect(transport);
84
+ _stderr.write(`[alpha-mcp] Server started (v${pkg.version})\n`);
@@ -0,0 +1,94 @@
1
+ import {
2
+ searchByEmbedding,
3
+ searchByKeyword,
4
+ agenticSearch,
5
+ getPaperContent,
6
+ answerPdfQuery,
7
+ readGithubRepo,
8
+ } from '../lib/alphaxiv.js';
9
+ import { writeAnnotation, readAnnotation, clearAnnotation, listAnnotations } from '../lib/annotations.js';
10
+ import { normalizePaperId, toArxivUrl } from '../lib/papers.js';
11
+
12
+ function textResult(data) {
13
+ return {
14
+ content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }],
15
+ };
16
+ }
17
+
18
+ function errorResult(message) {
19
+ return {
20
+ content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
21
+ isError: true,
22
+ };
23
+ }
24
+
25
+ export async function handleSearch({ query, mode = 'semantic' }) {
26
+ try {
27
+ if (mode === 'keyword') return textResult(await searchByKeyword(query));
28
+ if (mode === 'agentic') return textResult(await agenticSearch(query));
29
+ return textResult(await searchByEmbedding(query));
30
+ } catch (err) {
31
+ return errorResult(`Search failed: ${err.message}`);
32
+ }
33
+ }
34
+
35
+ export async function handleGet({ url, full_text = false }) {
36
+ try {
37
+ const paperId = normalizePaperId(url);
38
+ const arxivUrl = toArxivUrl(url);
39
+
40
+ const content = await getPaperContent(arxivUrl, { fullText: full_text });
41
+ const annotation = readAnnotation(paperId);
42
+
43
+ return textResult({ content, annotation });
44
+ } catch (err) {
45
+ return errorResult(`Failed to fetch paper: ${err.message}`);
46
+ }
47
+ }
48
+
49
+ export async function handleAsk({ url, question }) {
50
+ try {
51
+ const answer = await answerPdfQuery(toArxivUrl(url), question);
52
+ return textResult(answer);
53
+ } catch (err) {
54
+ return errorResult(`Ask failed: ${err.message}`);
55
+ }
56
+ }
57
+
58
+ export async function handleAnnotate({ id, note, clear = false, list = false }) {
59
+ try {
60
+ if (list) {
61
+ const all = listAnnotations();
62
+ return textResult({ annotations: all, total: all.length });
63
+ }
64
+
65
+ if (!id) return errorResult('Provide a paper ID or use list mode.');
66
+
67
+ const paperId = normalizePaperId(id);
68
+
69
+ if (clear) {
70
+ const removed = clearAnnotation(paperId);
71
+ return textResult({ status: removed ? 'cleared' : 'not_found', id: paperId });
72
+ }
73
+
74
+ if (note) {
75
+ const saved = writeAnnotation(paperId, note);
76
+ return textResult({ status: 'saved', annotation: saved });
77
+ }
78
+
79
+ const existing = readAnnotation(paperId);
80
+ if (existing) return textResult({ annotation: existing });
81
+ return textResult({ status: 'no_annotation', id: paperId });
82
+ } catch (err) {
83
+ return errorResult(`Annotation failed: ${err.message}`);
84
+ }
85
+ }
86
+
87
+ export async function handleCode({ github_url, path = '/' }) {
88
+ try {
89
+ const result = await readGithubRepo(github_url, path);
90
+ return textResult(result);
91
+ } catch (err) {
92
+ return errorResult(`Code read failed: ${err.message}`);
93
+ }
94
+ }