readdit-later-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { homedir, platform } from 'os';
6
+ import { execSync } from 'child_process';
7
+
8
+ const args = process.argv.slice(2);
9
+
10
+ if (args[0] === 'setup') {
11
+ setup();
12
+ } else {
13
+ // Default: run the MCP server
14
+ await import('../src/index.js');
15
+ }
16
+
17
+ function setup() {
18
+ console.log('\n Readdit Later MCP — Setup\n');
19
+
20
+ const configs = getConfigPaths();
21
+ let configured = false;
22
+
23
+ for (const { name, path, key } of configs) {
24
+ if (!path) continue;
25
+
26
+ try {
27
+ const dir = join(path, '..');
28
+ if (!existsSync(dir)) continue;
29
+
30
+ if (!existsSync(path)) writeFileSync(path, '{}');
31
+
32
+ const raw = readFileSync(path, 'utf8');
33
+ const config = JSON.parse(raw || '{}');
34
+
35
+ if (!config.mcpServers) config.mcpServers = {};
36
+
37
+ if (config.mcpServers['readdit-later']) {
38
+ console.log(` ✓ ${name} — already configured`);
39
+ configured = true;
40
+ continue;
41
+ }
42
+
43
+ // Find npx path
44
+ const npxCmd = platform() === 'win32' ? 'npx.cmd' : 'npx';
45
+ let npxPath;
46
+ try {
47
+ npxPath = execSync(`${platform() === 'win32' ? 'where' : 'which'} npx`, { encoding: 'utf8' }).trim().split('\n')[0].trim();
48
+ } catch {
49
+ npxPath = npxCmd;
50
+ }
51
+
52
+ config.mcpServers['readdit-later'] = {
53
+ command: npxPath,
54
+ args: ['readdit-later-mcp']
55
+ };
56
+
57
+ writeFileSync(path, JSON.stringify(config, null, 2));
58
+ console.log(` ✓ ${name} — configured at ${path}`);
59
+ configured = true;
60
+ } catch (err) {
61
+ console.log(` ✗ ${name} — failed: ${err.message}`);
62
+ }
63
+ }
64
+
65
+ if (!configured) {
66
+ console.log(' No AI tool config files found. Add this manually:\n');
67
+ printManualConfig();
68
+ }
69
+
70
+ console.log('\n Next steps:');
71
+ console.log(' 1. Restart your AI tool (Claude Desktop, Cursor, etc.)');
72
+ console.log(' 2. Open Chrome — the Readdit Later extension connects automatically');
73
+ console.log(' 3. Ask Claude: "Search my saved Reddit posts about TypeScript"\n');
74
+ }
75
+
76
+ function getConfigPaths() {
77
+ const home = homedir();
78
+ const p = platform();
79
+
80
+ let claudeDir;
81
+ if (p === 'win32') claudeDir = join(process.env.APPDATA || '', 'Claude');
82
+ else if (p === 'darwin') claudeDir = join(home, 'Library', 'Application Support', 'Claude');
83
+ else claudeDir = join(home, '.config', 'Claude');
84
+
85
+ return [
86
+ { name: 'Claude Desktop', path: join(claudeDir, 'claude_desktop_config.json'), key: 'mcpServers' },
87
+ { name: 'Cursor', path: join(home, '.cursor', 'mcp.json'), key: 'mcpServers' },
88
+ { name: 'Windsurf', path: join(home, '.windsurf', 'mcp.json'), key: 'mcpServers' }
89
+ ];
90
+ }
91
+
92
+ function printManualConfig() {
93
+ console.log(` {
94
+ "mcpServers": {
95
+ "readdit-later": {
96
+ "command": "npx",
97
+ "args": ["readdit-later-mcp"]
98
+ }
99
+ }
100
+ }`);
101
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "readdit-later-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Readdit Later — manage your Reddit saves from Claude, Cursor, and other AI tools",
5
+ "type": "module",
6
+ "bin": {
7
+ "readdit-later-mcp": "./bin/cli.js"
8
+ },
9
+ "main": "./src/index.js",
10
+ "scripts": {
11
+ "start": "node src/index.js"
12
+ },
13
+ "dependencies": {
14
+ "@modelcontextprotocol/sdk": "^1.12.1",
15
+ "ws": "^8.18.0"
16
+ },
17
+ "keywords": ["mcp", "readdit-later", "reddit", "bookmarks", "claude", "cursor"],
18
+ "license": "MIT",
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ }
22
+ }
package/src/bridge.js ADDED
@@ -0,0 +1,135 @@
1
+ import { WebSocketServer } from 'ws';
2
+
3
+ const PORT = 52849;
4
+ const COMMAND_TIMEOUT = 15000;
5
+
6
+ export class Bridge {
7
+ constructor(store) {
8
+ this.store = store;
9
+ this.wss = null;
10
+ this.extension = null;
11
+ this.pendingCommands = new Map();
12
+ this._commandId = 0;
13
+ }
14
+
15
+ start() {
16
+ this.wss = new WebSocketServer({ port: PORT, host: '127.0.0.1' });
17
+
18
+ this.wss.on('connection', (ws) => {
19
+ this.extension = ws;
20
+ this._log('Extension connected');
21
+
22
+ ws.on('message', (raw) => {
23
+ try {
24
+ const msg = JSON.parse(raw.toString());
25
+ this._handleMessage(msg);
26
+ } catch {
27
+ // Ignore malformed messages
28
+ }
29
+ });
30
+
31
+ ws.on('close', () => {
32
+ this._log('Extension disconnected');
33
+ this.extension = null;
34
+ this._rejectAllPending('Extension disconnected');
35
+ });
36
+
37
+ ws.on('error', () => {
38
+ this.extension = null;
39
+ this._rejectAllPending('Extension connection error');
40
+ });
41
+
42
+ this._send({ type: 'request_sync' });
43
+ });
44
+
45
+ this.wss.on('error', (err) => {
46
+ if (err.code === 'EADDRINUSE') {
47
+ this._log(`Port ${PORT} in use — another instance may be running`);
48
+ }
49
+ });
50
+
51
+ this._log(`Bridge listening on ws://127.0.0.1:${PORT}`);
52
+ }
53
+
54
+ get isConnected() {
55
+ return this.extension !== null && this.extension.readyState === 1;
56
+ }
57
+
58
+ async sendCommand(action, params) {
59
+ if (!this.isConnected) {
60
+ throw new Error('Extension not connected. Open Chrome with Readdit Later installed.');
61
+ }
62
+
63
+ const id = String(++this._commandId);
64
+
65
+ return new Promise((resolve, reject) => {
66
+ const timer = setTimeout(() => {
67
+ this.pendingCommands.delete(id);
68
+ reject(new Error('Command timed out — extension did not respond'));
69
+ }, COMMAND_TIMEOUT);
70
+
71
+ this.pendingCommands.set(id, { resolve, reject, timer });
72
+ this._send({ type: 'command', id, action, params });
73
+ });
74
+ }
75
+
76
+ _handleMessage(msg) {
77
+ switch (msg.type) {
78
+ case 'sync':
79
+ this.store.sync(msg.posts, msg.collections);
80
+ this._log(`Synced ${this.store.posts.length} posts, ${this.store.collections.length} collections`);
81
+ break;
82
+
83
+ case 'post_updated':
84
+ if (msg.post) this.store.updatePost(msg.post);
85
+ break;
86
+
87
+ case 'post_deleted':
88
+ if (msg.postId) this.store.removePost(msg.postId);
89
+ break;
90
+
91
+ case 'collections_updated':
92
+ if (msg.collections) this.store.updateCollections(msg.collections);
93
+ break;
94
+
95
+ case 'command_result': {
96
+ const pending = this.pendingCommands.get(msg.id);
97
+ if (pending) {
98
+ clearTimeout(pending.timer);
99
+ this.pendingCommands.delete(msg.id);
100
+ if (msg.success) {
101
+ pending.resolve(msg.data);
102
+ } else {
103
+ pending.reject(new Error(msg.error || 'Command failed'));
104
+ }
105
+ }
106
+ break;
107
+ }
108
+
109
+ case 'pong':
110
+ break;
111
+ }
112
+ }
113
+
114
+ _send(msg) {
115
+ if (this.isConnected) {
116
+ this.extension.send(JSON.stringify(msg));
117
+ }
118
+ }
119
+
120
+ _rejectAllPending(reason) {
121
+ for (const [id, { reject, timer }] of this.pendingCommands) {
122
+ clearTimeout(timer);
123
+ reject(new Error(reason));
124
+ }
125
+ this.pendingCommands.clear();
126
+ }
127
+
128
+ _log(msg) {
129
+ process.stderr.write(`[readdit-mcp] ${msg}\n`);
130
+ }
131
+
132
+ stop() {
133
+ if (this.wss) this.wss.close();
134
+ }
135
+ }
package/src/index.js ADDED
@@ -0,0 +1,22 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { Store } from './store.js';
4
+ import { Bridge } from './bridge.js';
5
+ import { registerTools } from './tools.js';
6
+
7
+ const store = new Store();
8
+ const bridge = new Bridge(store);
9
+
10
+ const server = new McpServer({
11
+ name: 'readdit-later',
12
+ version: '1.0.0'
13
+ });
14
+
15
+ registerTools(server, store, bridge);
16
+
17
+ bridge.start();
18
+
19
+ const transport = new StdioServerTransport();
20
+ await server.connect(transport);
21
+
22
+ process.stderr.write('[readdit-mcp] MCP server running — waiting for extension connection\n');
package/src/store.js ADDED
@@ -0,0 +1,206 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const DATA_DIR = join(homedir(), '.readdit-later');
6
+ const DATA_FILE = join(DATA_DIR, 'data.json');
7
+
8
+ export class Store {
9
+ constructor() {
10
+ this.posts = [];
11
+ this.collections = [];
12
+ this.postIndex = new Map();
13
+ this.lastSyncTime = null;
14
+ this._load();
15
+ }
16
+
17
+ _load() {
18
+ try {
19
+ if (existsSync(DATA_FILE)) {
20
+ const raw = readFileSync(DATA_FILE, 'utf8');
21
+ const data = JSON.parse(raw);
22
+ this.posts = data.posts || [];
23
+ this.collections = data.collections || [];
24
+ this.lastSyncTime = data.lastSyncTime || null;
25
+ this._rebuildIndex();
26
+ }
27
+ } catch {
28
+ // Start fresh if file is corrupt
29
+ }
30
+ }
31
+
32
+ _save() {
33
+ try {
34
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
35
+ writeFileSync(DATA_FILE, JSON.stringify({
36
+ posts: this.posts,
37
+ collections: this.collections,
38
+ lastSyncTime: this.lastSyncTime
39
+ }));
40
+ } catch {
41
+ // Non-critical — data will be re-synced from extension
42
+ }
43
+ }
44
+
45
+ _rebuildIndex() {
46
+ this.postIndex.clear();
47
+ for (const post of this.posts) {
48
+ this.postIndex.set(post.id, post);
49
+ }
50
+ }
51
+
52
+ sync(posts, collections) {
53
+ this.posts = posts || [];
54
+ this.collections = collections || [];
55
+ this.lastSyncTime = new Date().toISOString();
56
+ this._rebuildIndex();
57
+ this._save();
58
+ }
59
+
60
+ updatePost(post) {
61
+ const idx = this.posts.findIndex(p => p.id === post.id);
62
+ if (idx !== -1) {
63
+ this.posts[idx] = { ...this.posts[idx], ...post };
64
+ this.postIndex.set(post.id, this.posts[idx]);
65
+ } else {
66
+ this.posts.push(post);
67
+ this.postIndex.set(post.id, post);
68
+ }
69
+ this._save();
70
+ }
71
+
72
+ removePost(postId) {
73
+ this.posts = this.posts.filter(p => p.id !== postId);
74
+ this.postIndex.delete(postId);
75
+ this._save();
76
+ }
77
+
78
+ getPost(id) {
79
+ return this.postIndex.get(id) || null;
80
+ }
81
+
82
+ searchPosts({ query, subreddit, tag, content_type, read_status, author, min_score, min_comments, time_filter, item_type, sort_by, limit }) {
83
+ let results = [...this.posts];
84
+
85
+ if (query) {
86
+ const q = query.toLowerCase();
87
+ const terms = q.split(/\s+/);
88
+ results = results.filter(p => {
89
+ const text = `${p.title || ''} ${p.subreddit || ''} ${p.selftext || ''} ${p.comment_body || ''} ${p.author || ''} ${p.note || ''} ${(p.tags || p.labels || []).join(' ')} ${p.source_post_title || ''}`.toLowerCase();
90
+ return terms.every(t => text.includes(t));
91
+ });
92
+ }
93
+
94
+ if (subreddit) {
95
+ const sub = subreddit.toLowerCase().replace(/^r\//, '');
96
+ results = results.filter(p => (p.subreddit || '').toLowerCase() === sub);
97
+ }
98
+
99
+ if (tag) {
100
+ results = results.filter(p => {
101
+ const tags = p.tags || p.labels || [];
102
+ return tags.some(t => t.toLowerCase() === tag.toLowerCase());
103
+ });
104
+ }
105
+
106
+ if (content_type) {
107
+ results = results.filter(p => this._matchesType(p, content_type));
108
+ }
109
+
110
+ if (read_status === 'read') results = results.filter(p => p.is_read === true);
111
+ if (read_status === 'unread') results = results.filter(p => p.is_read !== true);
112
+
113
+ if (author) {
114
+ const a = author.toLowerCase().replace(/^u\//, '');
115
+ results = results.filter(p => (p.author || '').toLowerCase() === a);
116
+ }
117
+
118
+ if (min_score) results = results.filter(p => (p.score || 0) >= min_score);
119
+ if (min_comments) results = results.filter(p => (p.num_comments || 0) >= min_comments);
120
+
121
+ if (item_type === 'comment') results = results.filter(p => p.is_comment || p.item_type === 'comment' || p.kind === 't1');
122
+ if (item_type === 'post') results = results.filter(p => !p.is_comment && p.item_type !== 'comment' && p.kind !== 't1');
123
+
124
+ if (time_filter) {
125
+ const now = Date.now() / 1000;
126
+ const limits = { day: 86400, week: 604800, month: 2592000, year: 31536000 };
127
+ const secs = limits[time_filter];
128
+ if (secs) results = results.filter(p => (p.created_utc || 0) >= now - secs);
129
+ }
130
+
131
+ switch (sort_by) {
132
+ case 'oldest': results.sort((a, b) => (a.created_utc || 0) - (b.created_utc || 0)); break;
133
+ case 'most_upvoted': results.sort((a, b) => (b.score || 0) - (a.score || 0)); break;
134
+ case 'most_commented': results.sort((a, b) => (b.num_comments || 0) - (a.num_comments || 0)); break;
135
+ case 'alphabetical': results.sort((a, b) => (a.title || '').localeCompare(b.title || '')); break;
136
+ default: results.sort((a, b) => (b.created_utc || 0) - (a.created_utc || 0));
137
+ }
138
+
139
+ if (limit && limit > 0) results = results.slice(0, limit);
140
+
141
+ return results;
142
+ }
143
+
144
+ getStats() {
145
+ const subredditCounts = {};
146
+ const tagCounts = {};
147
+ let readCount = 0;
148
+ let commentCount = 0;
149
+
150
+ for (const p of this.posts) {
151
+ subredditCounts[p.subreddit] = (subredditCounts[p.subreddit] || 0) + 1;
152
+ if (p.is_read) readCount++;
153
+ if (p.is_comment || p.item_type === 'comment') commentCount++;
154
+ for (const t of (p.tags || p.labels || [])) {
155
+ tagCounts[t] = (tagCounts[t] || 0) + 1;
156
+ }
157
+ }
158
+
159
+ return {
160
+ total_posts: this.posts.length,
161
+ total_comments: commentCount,
162
+ read: readCount,
163
+ unread: this.posts.length - readCount,
164
+ subreddits: Object.keys(subredditCounts).length,
165
+ labels: Object.keys(tagCounts).length,
166
+ collections: this.collections.length,
167
+ last_sync: this.lastSyncTime,
168
+ top_subreddits: Object.entries(subredditCounts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([name, count]) => ({ name, count })),
169
+ top_labels: Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([name, count]) => ({ name, count }))
170
+ };
171
+ }
172
+
173
+ getSubreddits() {
174
+ const counts = {};
175
+ for (const p of this.posts) {
176
+ counts[p.subreddit] = (counts[p.subreddit] || 0) + 1;
177
+ }
178
+ return Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, count }));
179
+ }
180
+
181
+ getLabels() {
182
+ const counts = {};
183
+ for (const p of this.posts) {
184
+ for (const t of (p.tags || p.labels || [])) {
185
+ counts[t] = (counts[t] || 0) + 1;
186
+ }
187
+ }
188
+ return Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, count }));
189
+ }
190
+
191
+ updateCollections(collections) {
192
+ this.collections = collections || [];
193
+ this._save();
194
+ }
195
+
196
+ _matchesType(post, type) {
197
+ const url = post.url || '';
198
+ switch (type) {
199
+ case 'image': return /i\.redd\.it|imgur\.com|\.(jpg|jpeg|png|gif|webp)$/i.test(url);
200
+ case 'video': return /v\.redd\.it|youtube\.com|youtu\.be|\.(mp4|webm)$/i.test(url);
201
+ case 'text': return post.is_self && (post.selftext || '').trim().length > 0;
202
+ case 'link': return !post.is_self && !this._matchesType(post, 'image') && !this._matchesType(post, 'video');
203
+ default: return true;
204
+ }
205
+ }
206
+ }
package/src/tools.js ADDED
@@ -0,0 +1,364 @@
1
+ import { z } from 'zod';
2
+
3
+ export function registerTools(server, store, bridge) {
4
+
5
+ // ── Read Tools (work from cached data) ─────────────────────────
6
+
7
+ server.tool(
8
+ 'search_posts',
9
+ 'Search saved Reddit posts by keyword, subreddit, tag, author, content type, read status, or date range',
10
+ {
11
+ query: z.string().optional().describe('Search keywords (matches title, body, subreddit, author, notes, labels)'),
12
+ subreddit: z.string().optional().describe('Filter by subreddit name (without r/ prefix)'),
13
+ tag: z.string().optional().describe('Filter by label/tag name'),
14
+ content_type: z.enum(['image', 'video', 'text', 'link']).optional().describe('Filter by post type'),
15
+ read_status: z.enum(['read', 'unread']).optional().describe('Filter by read/unread status'),
16
+ author: z.string().optional().describe('Filter by Reddit username (without u/ prefix)'),
17
+ item_type: z.enum(['post', 'comment']).optional().describe('Filter posts vs saved comments'),
18
+ min_score: z.number().optional().describe('Minimum upvote score'),
19
+ min_comments: z.number().optional().describe('Minimum comment count'),
20
+ time_filter: z.enum(['day', 'week', 'month', 'year']).optional().describe('Filter by age'),
21
+ sort_by: z.enum(['newest', 'oldest', 'most_upvoted', 'most_commented', 'alphabetical']).optional().describe('Sort order (default: newest)'),
22
+ limit: z.number().optional().describe('Max results to return')
23
+ },
24
+ async (params) => {
25
+ const results = store.searchPosts(params);
26
+ const summaries = results.map(formatPost);
27
+ return {
28
+ content: [{
29
+ type: 'text',
30
+ text: results.length === 0
31
+ ? 'No saved posts match that query.'
32
+ : `Found ${results.length} post${results.length === 1 ? '' : 's'}:\n\n${summaries.join('\n\n')}`
33
+ }]
34
+ };
35
+ }
36
+ );
37
+
38
+ server.tool(
39
+ 'get_post',
40
+ 'Get full details of a specific saved post by its ID',
41
+ { post_id: z.string().describe('The post ID') },
42
+ async ({ post_id }) => {
43
+ const post = store.getPost(post_id);
44
+ if (!post) return { content: [{ type: 'text', text: 'Post not found.' }] };
45
+ return {
46
+ content: [{
47
+ type: 'text',
48
+ text: JSON.stringify({
49
+ id: post.id,
50
+ title: post.title,
51
+ subreddit: post.subreddit,
52
+ author: post.author,
53
+ url: post.url,
54
+ score: post.score,
55
+ num_comments: post.num_comments,
56
+ content: post.selftext || post.comment_body || '',
57
+ labels: post.tags || post.labels || [],
58
+ note: post.note || '',
59
+ ai_summary: post.ai_summary || '',
60
+ is_read: post.is_read || false,
61
+ saved_date: post.saved_date,
62
+ created_utc: post.created_utc,
63
+ item_type: post.is_comment ? 'comment' : 'post'
64
+ }, null, 2)
65
+ }]
66
+ };
67
+ }
68
+ );
69
+
70
+ server.tool(
71
+ 'get_stats',
72
+ 'Get library statistics — total posts, subreddits, labels, read/unread counts, top subreddits and labels',
73
+ {},
74
+ async () => {
75
+ const stats = store.getStats();
76
+ return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
77
+ }
78
+ );
79
+
80
+ server.tool(
81
+ 'list_subreddits',
82
+ 'List all subreddits in the saved library with post counts',
83
+ {},
84
+ async () => {
85
+ const subs = store.getSubreddits();
86
+ return {
87
+ content: [{
88
+ type: 'text',
89
+ text: subs.length === 0
90
+ ? 'No subreddits found.'
91
+ : subs.map(s => `r/${s.name}: ${s.count} post${s.count === 1 ? '' : 's'}`).join('\n')
92
+ }]
93
+ };
94
+ }
95
+ );
96
+
97
+ server.tool(
98
+ 'list_labels',
99
+ 'List all labels/tags in the saved library with post counts',
100
+ {},
101
+ async () => {
102
+ const labels = store.getLabels();
103
+ return {
104
+ content: [{
105
+ type: 'text',
106
+ text: labels.length === 0
107
+ ? 'No labels found.'
108
+ : labels.map(l => `${l.name}: ${l.count} post${l.count === 1 ? '' : 's'}`).join('\n')
109
+ }]
110
+ };
111
+ }
112
+ );
113
+
114
+ server.tool(
115
+ 'list_collections',
116
+ 'List all reading collections/lists',
117
+ {},
118
+ async () => {
119
+ if (bridge.isConnected) {
120
+ try {
121
+ const result = await bridge.sendCommand('list_collections', {});
122
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
123
+ } catch { /* fall through to cached */ }
124
+ }
125
+ const cols = store.collections;
126
+ return {
127
+ content: [{
128
+ type: 'text',
129
+ text: cols.length === 0
130
+ ? 'No collections found.'
131
+ : JSON.stringify(cols, null, 2)
132
+ }]
133
+ };
134
+ }
135
+ );
136
+
137
+ // ── Write Tools (require live extension connection) ────────────
138
+
139
+ server.tool(
140
+ 'label_posts',
141
+ 'Add a label/tag to one or more posts. Search first to get post IDs.',
142
+ {
143
+ post_ids: z.array(z.string()).describe('Array of post IDs to label'),
144
+ label: z.string().describe('The label to add')
145
+ },
146
+ async (params) => {
147
+ return await execCommand(bridge, 'label_posts', params);
148
+ }
149
+ );
150
+
151
+ server.tool(
152
+ 'remove_label',
153
+ 'Remove a label/tag from one or more posts',
154
+ {
155
+ post_ids: z.array(z.string()).describe('Array of post IDs'),
156
+ label: z.string().describe('The label to remove')
157
+ },
158
+ async (params) => {
159
+ return await execCommand(bridge, 'remove_label', params);
160
+ }
161
+ );
162
+
163
+ server.tool(
164
+ 'mark_read',
165
+ 'Mark one or more posts as read or unread',
166
+ {
167
+ post_ids: z.array(z.string()).describe('Array of post IDs'),
168
+ is_read: z.boolean().describe('true = mark as read, false = mark as unread')
169
+ },
170
+ async (params) => {
171
+ return await execCommand(bridge, 'mark_read', params);
172
+ }
173
+ );
174
+
175
+ server.tool(
176
+ 'add_note',
177
+ 'Add a personal note to one or more posts',
178
+ {
179
+ post_ids: z.array(z.string()).describe('Array of post IDs'),
180
+ note: z.string().describe('The note text to attach')
181
+ },
182
+ async (params) => {
183
+ return await execCommand(bridge, 'add_note', params);
184
+ }
185
+ );
186
+
187
+ server.tool(
188
+ 'delete_posts',
189
+ 'Delete one or more saved posts permanently',
190
+ {
191
+ post_ids: z.array(z.string()).describe('Array of post IDs to delete')
192
+ },
193
+ async (params) => {
194
+ return await execCommand(bridge, 'delete_posts', params);
195
+ }
196
+ );
197
+
198
+ server.tool(
199
+ 'create_collection',
200
+ 'Create a new reading collection/list with selected posts',
201
+ {
202
+ name: z.string().describe('Collection name'),
203
+ description: z.string().optional().describe('Collection description'),
204
+ post_ids: z.array(z.string()).describe('Array of post IDs to include')
205
+ },
206
+ async (params) => {
207
+ return await execCommand(bridge, 'create_collection', params);
208
+ }
209
+ );
210
+
211
+ server.tool(
212
+ 'add_to_collection',
213
+ 'Add posts to an existing collection',
214
+ {
215
+ collection_name: z.string().describe('Name of the collection'),
216
+ post_ids: z.array(z.string()).describe('Array of post IDs to add')
217
+ },
218
+ async (params) => {
219
+ return await execCommand(bridge, 'add_to_collection', params);
220
+ }
221
+ );
222
+
223
+ server.tool(
224
+ 'remove_from_collection',
225
+ 'Remove posts from a collection',
226
+ {
227
+ collection_name: z.string().describe('Name of the collection'),
228
+ post_ids: z.array(z.string()).describe('Array of post IDs to remove')
229
+ },
230
+ async (params) => {
231
+ return await execCommand(bridge, 'remove_from_collection', params);
232
+ }
233
+ );
234
+
235
+ server.tool(
236
+ 'delete_collection',
237
+ 'Delete a collection (posts are not deleted)',
238
+ {
239
+ collection_name: z.string().describe('Name of the collection to delete')
240
+ },
241
+ async (params) => {
242
+ return await execCommand(bridge, 'delete_collection', params);
243
+ }
244
+ );
245
+
246
+ server.tool(
247
+ 'export_posts',
248
+ 'Export posts as JSON, CSV, or Markdown',
249
+ {
250
+ post_ids: z.array(z.string()).optional().describe('Specific post IDs to export (omit for all)'),
251
+ format: z.enum(['json', 'csv', 'markdown']).optional().describe('Export format (default: json)')
252
+ },
253
+ async ({ post_ids, format = 'json' }) => {
254
+ const posts = post_ids
255
+ ? post_ids.map(id => store.getPost(id)).filter(Boolean)
256
+ : store.posts;
257
+
258
+ if (posts.length === 0) return { content: [{ type: 'text', text: 'No posts to export.' }] };
259
+
260
+ let output;
261
+ switch (format) {
262
+ case 'csv':
263
+ output = postsToCSV(posts);
264
+ break;
265
+ case 'markdown':
266
+ output = postsToMarkdown(posts);
267
+ break;
268
+ default:
269
+ output = JSON.stringify(posts.map(p => ({
270
+ id: p.id, title: p.title, subreddit: p.subreddit, author: p.author,
271
+ url: p.url, score: p.score, num_comments: p.num_comments,
272
+ labels: p.tags || p.labels || [], note: p.note || '',
273
+ ai_summary: p.ai_summary || '', content: p.selftext || p.comment_body || '',
274
+ is_read: p.is_read || false, saved_date: p.saved_date
275
+ })), null, 2);
276
+ }
277
+
278
+ return { content: [{ type: 'text', text: output }] };
279
+ }
280
+ );
281
+
282
+ server.tool(
283
+ 'connection_status',
284
+ 'Check if the Chrome extension is connected and synced',
285
+ {},
286
+ async () => {
287
+ return {
288
+ content: [{
289
+ type: 'text',
290
+ text: JSON.stringify({
291
+ extension_connected: bridge.isConnected,
292
+ posts_in_cache: store.posts.length,
293
+ last_sync: store.lastSyncTime,
294
+ collections: store.collections.length
295
+ }, null, 2)
296
+ }]
297
+ };
298
+ }
299
+ );
300
+ }
301
+
302
+ // ── Helpers ──────────────────────────────────────────────────────
303
+
304
+ async function execCommand(bridge, action, params) {
305
+ try {
306
+ const result = await bridge.sendCommand(action, params);
307
+ return {
308
+ content: [{
309
+ type: 'text',
310
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
311
+ }]
312
+ };
313
+ } catch (err) {
314
+ return {
315
+ content: [{ type: 'text', text: `Error: ${err.message}` }],
316
+ isError: true
317
+ };
318
+ }
319
+ }
320
+
321
+ function formatPost(p) {
322
+ const labels = (p.tags || p.labels || []);
323
+ const parts = [
324
+ `**${p.title || 'Untitled'}**`,
325
+ `r/${p.subreddit} · u/${p.author} · ${p.score || 0} pts · ${p.num_comments || 0} comments`,
326
+ `ID: ${p.id} · ${p.url || ''}`
327
+ ];
328
+ if (labels.length) parts.push(`Labels: ${labels.join(', ')}`);
329
+ if (p.note) parts.push(`Note: ${p.note}`);
330
+ if (p.ai_summary) parts.push(`Summary: ${p.ai_summary}`);
331
+ if (p.is_read) parts.push('(Read)');
332
+ return parts.join('\n');
333
+ }
334
+
335
+ function postsToCSV(posts) {
336
+ const esc = (v) => {
337
+ const s = String(v ?? '');
338
+ return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
339
+ };
340
+ const headers = ['Title', 'URL', 'Subreddit', 'Author', 'Score', 'Comments', 'Labels', 'Note', 'AI Summary', 'Read'];
341
+ const rows = posts.map(p => [
342
+ esc(p.title), esc(p.url), esc(p.subreddit), esc(p.author),
343
+ p.score || 0, p.num_comments || 0,
344
+ esc((p.tags || p.labels || []).join('; ')), esc(p.note),
345
+ esc(p.ai_summary), p.is_read ? 'Yes' : 'No'
346
+ ].join(','));
347
+ return [headers.join(','), ...rows].join('\n');
348
+ }
349
+
350
+ function postsToMarkdown(posts) {
351
+ return posts.map(p => {
352
+ const labels = (p.tags || p.labels || []);
353
+ let md = `## ${p.title || 'Untitled'}\n\n`;
354
+ md += `- **Subreddit:** r/${p.subreddit}\n`;
355
+ md += `- **Author:** u/${p.author}\n`;
356
+ md += `- **Score:** ${p.score || 0} | **Comments:** ${p.num_comments || 0}\n`;
357
+ md += `- **URL:** ${p.url || 'N/A'}\n`;
358
+ if (labels.length) md += `- **Labels:** ${labels.join(', ')}\n`;
359
+ if (p.note) md += `- **Note:** ${p.note}\n`;
360
+ if (p.ai_summary) md += `- **Summary:** ${p.ai_summary}\n`;
361
+ if (p.selftext || p.comment_body) md += `\n${(p.selftext || p.comment_body).slice(0, 500)}\n`;
362
+ return md;
363
+ }).join('\n---\n\n');
364
+ }