ofw-mcp 2.0.4 → 2.0.5

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/dist/cache.js ADDED
@@ -0,0 +1,192 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { getCacheDbPath } from './config.js';
5
+ let instance = null;
6
+ const SCHEMA_V1 = `
7
+ CREATE TABLE IF NOT EXISTS messages (
8
+ id INTEGER PRIMARY KEY,
9
+ folder TEXT NOT NULL,
10
+ subject TEXT NOT NULL,
11
+ from_user TEXT NOT NULL,
12
+ sent_at TEXT NOT NULL,
13
+ recipients_json TEXT NOT NULL,
14
+ body TEXT,
15
+ fetched_body_at TEXT,
16
+ reply_to_id INTEGER,
17
+ chain_root_id INTEGER,
18
+ list_data_json TEXT NOT NULL,
19
+ last_seen_at TEXT NOT NULL
20
+ );
21
+ CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC);
22
+ CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id);
23
+
24
+ CREATE TABLE IF NOT EXISTS drafts (
25
+ id INTEGER PRIMARY KEY,
26
+ subject TEXT NOT NULL,
27
+ body TEXT NOT NULL,
28
+ recipients_json TEXT NOT NULL,
29
+ reply_to_id INTEGER,
30
+ modified_at TEXT NOT NULL,
31
+ list_data_json TEXT NOT NULL
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS sync_state (
35
+ folder TEXT PRIMARY KEY,
36
+ last_sync_at TEXT NOT NULL,
37
+ newest_id INTEGER
38
+ );
39
+
40
+ CREATE TABLE IF NOT EXISTS meta (
41
+ key TEXT PRIMARY KEY,
42
+ value TEXT NOT NULL
43
+ );
44
+ `;
45
+ function migrate(db) {
46
+ db.exec(SCHEMA_V1);
47
+ db.prepare('INSERT OR IGNORE INTO meta(key, value) VALUES(?, ?)').run('schema_version', '1');
48
+ }
49
+ export function openCache() {
50
+ if (instance)
51
+ return instance;
52
+ const path = getCacheDbPath();
53
+ mkdirSync(dirname(path), { recursive: true });
54
+ const db = new DatabaseSync(path);
55
+ db.exec('PRAGMA journal_mode = WAL');
56
+ db.exec('PRAGMA foreign_keys = ON');
57
+ migrate(db);
58
+ instance = { db };
59
+ return instance;
60
+ }
61
+ export function closeCache() {
62
+ if (instance) {
63
+ instance.db.close();
64
+ instance = null;
65
+ }
66
+ }
67
+ function rowFromDb(r) {
68
+ return {
69
+ id: r.id,
70
+ folder: r.folder,
71
+ subject: r.subject,
72
+ fromUser: r.from_user,
73
+ sentAt: r.sent_at,
74
+ recipients: JSON.parse(r.recipients_json),
75
+ body: r.body,
76
+ fetchedBodyAt: r.fetched_body_at,
77
+ replyToId: r.reply_to_id,
78
+ chainRootId: r.chain_root_id,
79
+ listData: JSON.parse(r.list_data_json),
80
+ };
81
+ }
82
+ export function upsertMessage(row) {
83
+ const { db } = openCache();
84
+ db.prepare(`INSERT INTO messages (
85
+ id, folder, subject, from_user, sent_at, recipients_json,
86
+ body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
87
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
88
+ ON CONFLICT(id) DO UPDATE SET
89
+ folder=excluded.folder,
90
+ subject=excluded.subject,
91
+ from_user=excluded.from_user,
92
+ sent_at=excluded.sent_at,
93
+ recipients_json=excluded.recipients_json,
94
+ body=excluded.body,
95
+ fetched_body_at=excluded.fetched_body_at,
96
+ reply_to_id=excluded.reply_to_id,
97
+ chain_root_id=excluded.chain_root_id,
98
+ list_data_json=excluded.list_data_json,
99
+ last_seen_at=excluded.last_seen_at`).run(row.id, row.folder, row.subject, row.fromUser, row.sentAt, JSON.stringify(row.recipients), row.body, row.fetchedBodyAt, row.replyToId, row.chainRootId, JSON.stringify(row.listData), new Date().toISOString());
100
+ }
101
+ export function getMessage(id) {
102
+ const { db } = openCache();
103
+ const r = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
104
+ return r ? rowFromDb(r) : null;
105
+ }
106
+ export function listMessages(opts) {
107
+ const { db } = openCache();
108
+ const offset = (opts.page - 1) * opts.size;
109
+ const rows = db.prepare(`SELECT * FROM messages WHERE folder = ?
110
+ ORDER BY sent_at DESC, id DESC
111
+ LIMIT ? OFFSET ?`).all(opts.folder, opts.size, offset);
112
+ return rows.map(rowFromDb);
113
+ }
114
+ function draftFromDb(r) {
115
+ return {
116
+ id: r.id,
117
+ subject: r.subject,
118
+ body: r.body,
119
+ recipients: JSON.parse(r.recipients_json),
120
+ replyToId: r.reply_to_id,
121
+ modifiedAt: r.modified_at,
122
+ listData: JSON.parse(r.list_data_json),
123
+ };
124
+ }
125
+ export function upsertDraft(row) {
126
+ const { db } = openCache();
127
+ db.prepare(`INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
128
+ VALUES (?, ?, ?, ?, ?, ?, ?)
129
+ ON CONFLICT(id) DO UPDATE SET
130
+ subject=excluded.subject,
131
+ body=excluded.body,
132
+ recipients_json=excluded.recipients_json,
133
+ reply_to_id=excluded.reply_to_id,
134
+ modified_at=excluded.modified_at,
135
+ list_data_json=excluded.list_data_json`).run(row.id, row.subject, row.body, JSON.stringify(row.recipients), row.replyToId, row.modifiedAt, JSON.stringify(row.listData));
136
+ }
137
+ export function getDraft(id) {
138
+ const { db } = openCache();
139
+ const r = db.prepare('SELECT * FROM drafts WHERE id = ?').get(id);
140
+ return r ? draftFromDb(r) : null;
141
+ }
142
+ export function listDrafts(opts) {
143
+ const { db } = openCache();
144
+ const offset = (opts.page - 1) * opts.size;
145
+ const rows = db.prepare('SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?').all(opts.size, offset);
146
+ return rows.map(draftFromDb);
147
+ }
148
+ export function deleteDraft(id) {
149
+ const { db } = openCache();
150
+ db.prepare('DELETE FROM drafts WHERE id = ?').run(id);
151
+ }
152
+ export function listDraftIds() {
153
+ const { db } = openCache();
154
+ const rows = db.prepare('SELECT id FROM drafts').all();
155
+ return rows.map((r) => r.id);
156
+ }
157
+ export function getSyncState(folder) {
158
+ const { db } = openCache();
159
+ const r = db.prepare('SELECT last_sync_at, newest_id FROM sync_state WHERE folder = ?')
160
+ .get(folder);
161
+ if (!r)
162
+ return null;
163
+ return { lastSyncAt: r.last_sync_at, newestId: r.newest_id };
164
+ }
165
+ export function setSyncState(folder, state) {
166
+ const { db } = openCache();
167
+ db.prepare(`INSERT INTO sync_state (folder, last_sync_at, newest_id) VALUES (?, ?, ?)
168
+ ON CONFLICT(folder) DO UPDATE SET
169
+ last_sync_at = excluded.last_sync_at,
170
+ newest_id = excluded.newest_id`).run(folder, state.lastSyncAt, state.newestId);
171
+ }
172
+ export function getMeta(key) {
173
+ const { db } = openCache();
174
+ const r = db.prepare('SELECT value FROM meta WHERE key = ?')
175
+ .get(key);
176
+ return r ? r.value : null;
177
+ }
178
+ export function setMeta(key, value) {
179
+ const { db } = openCache();
180
+ db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, value);
181
+ }
182
+ export function findLatestReplyTip(replyToId) {
183
+ const { db } = openCache();
184
+ const parent = db.prepare('SELECT id, folder, chain_root_id FROM messages WHERE id = ?').get(replyToId);
185
+ if (!parent)
186
+ return replyToId;
187
+ const chainRoot = parent.chain_root_id ?? parent.id;
188
+ const tip = db.prepare(`SELECT id FROM messages
189
+ WHERE folder = 'sent' AND chain_root_id = ?
190
+ ORDER BY id DESC LIMIT 1`).get(chainRoot);
191
+ return tip ? tip.id : replyToId;
192
+ }
package/dist/config.js ADDED
@@ -0,0 +1,21 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ function readUsername() {
5
+ const raw = process.env.OFW_USERNAME;
6
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
7
+ throw new Error('OFW_USERNAME must be set to derive cache path');
8
+ }
9
+ return raw.trim();
10
+ }
11
+ export function getCacheDir() {
12
+ const override = process.env.OFW_CACHE_DIR;
13
+ if (override && override.trim().length > 0)
14
+ return override.trim();
15
+ return join(homedir(), '.cache', 'ofw-mcp');
16
+ }
17
+ export function getCacheDbPath() {
18
+ const username = readUsername();
19
+ const hash = createHash('sha256').update(username).digest('hex').slice(0, 16);
20
+ return join(getCacheDir(), `${hash}.db`);
21
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ const originalEmit = process.emit.bind(process);
3
+ process.emit = function (event, ...args) {
4
+ if (event === 'warning') {
5
+ const w = args[0];
6
+ if (w?.name === 'ExperimentalWarning' && /SQLite/i.test(w.message ?? '')) {
7
+ return false;
8
+ }
9
+ }
10
+ return originalEmit(event, ...args);
11
+ };
2
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
14
  import { client } from './client.js';
@@ -7,7 +17,7 @@ import { registerMessageTools } from './tools/messages.js';
7
17
  import { registerCalendarTools } from './tools/calendar.js';
8
18
  import { registerExpenseTools } from './tools/expenses.js';
9
19
  import { registerJournalTools } from './tools/journal.js';
10
- const server = new McpServer({ name: 'ofw', version: '2.0.4' });
20
+ const server = new McpServer({ name: 'ofw', version: '2.0.5' });
11
21
  registerUserTools(server, client);
12
22
  registerMessageTools(server, client);
13
23
  registerCalendarTools(server, client);
package/dist/sync.js ADDED
@@ -0,0 +1,149 @@
1
+ import { setMeta, upsertMessage, getMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, } from './cache.js';
2
+ export async function resolveFolderIds(client) {
3
+ const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
4
+ const sys = data.systemFolders ?? [];
5
+ const find = (type) => {
6
+ const f = sys.find((x) => x.folderType === type);
7
+ if (!f)
8
+ throw new Error(`OFW system folder not found: ${type}`);
9
+ return f.id;
10
+ };
11
+ const ids = {
12
+ inbox: find('INBOX'),
13
+ sent: find('SENT_MESSAGES'),
14
+ drafts: find('DRAFTS'),
15
+ };
16
+ setMeta('drafts_folder_id', ids.drafts);
17
+ return ids;
18
+ }
19
+ function recipientsFromList(item) {
20
+ return (item.recipients ?? []).map((r) => ({
21
+ userId: r.user.id,
22
+ name: r.user.name,
23
+ viewedAt: r.viewed?.dateTime ?? null,
24
+ }));
25
+ }
26
+ export async function syncMessageFolder(client, folder, folderId, opts) {
27
+ let page = 1;
28
+ let synced = 0;
29
+ let newestId = null;
30
+ const unread = [];
31
+ while (true) {
32
+ const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
33
+ const list = await client.request('GET', path);
34
+ const items = list.data ?? [];
35
+ if (items.length === 0)
36
+ break;
37
+ let pageSawKnownItem = false;
38
+ for (const item of items) {
39
+ if (newestId === null || item.id > newestId)
40
+ newestId = item.id;
41
+ const existing = getMessage(item.id);
42
+ if (existing) {
43
+ pageSawKnownItem = true;
44
+ continue;
45
+ }
46
+ const isInboxUnread = folder === 'inbox' && item.showNeverViewed === true;
47
+ const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
48
+ let body = null;
49
+ let fetchedBodyAt = null;
50
+ if (shouldFetchBody) {
51
+ const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
52
+ body = detail.body ?? '';
53
+ fetchedBodyAt = new Date().toISOString();
54
+ }
55
+ else {
56
+ unread.push({
57
+ id: item.id,
58
+ subject: item.subject,
59
+ from: item.from?.name ?? '',
60
+ sentAt: item.date.dateTime,
61
+ });
62
+ }
63
+ const row = {
64
+ id: item.id,
65
+ folder,
66
+ subject: item.subject,
67
+ fromUser: item.from?.name ?? '',
68
+ sentAt: item.date.dateTime,
69
+ recipients: recipientsFromList(item),
70
+ body,
71
+ fetchedBodyAt,
72
+ replyToId: null,
73
+ chainRootId: null,
74
+ listData: item,
75
+ };
76
+ upsertMessage(row);
77
+ synced++;
78
+ }
79
+ // OFW returns date-desc, so a known item means we've reached cached history.
80
+ if (pageSawKnownItem)
81
+ break;
82
+ page++;
83
+ }
84
+ setSyncState(folder, {
85
+ lastSyncAt: new Date().toISOString(),
86
+ newestId,
87
+ });
88
+ return { synced, unread };
89
+ }
90
+ export async function syncDrafts(client, draftsFolderId) {
91
+ const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=1&size=50&sort=date&sortDirection=desc`;
92
+ const list = await client.request('GET', path);
93
+ const items = list.data ?? [];
94
+ const seenIds = new Set();
95
+ let synced = 0;
96
+ for (const item of items) {
97
+ seenIds.add(item.id);
98
+ const existing = getDraft(item.id);
99
+ if (existing && existing.modifiedAt === item.date.dateTime) {
100
+ continue;
101
+ }
102
+ const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
103
+ const row = {
104
+ id: item.id,
105
+ subject: detail.subject ?? item.subject,
106
+ body: detail.body ?? '',
107
+ recipients: (item.recipients ?? []).map((r) => ({
108
+ userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
109
+ })),
110
+ replyToId: item.replyToId,
111
+ modifiedAt: item.date.dateTime,
112
+ listData: item,
113
+ };
114
+ upsertDraft(row);
115
+ synced++;
116
+ }
117
+ for (const id of listDraftIds()) {
118
+ if (!seenIds.has(id))
119
+ deleteDraft(id);
120
+ }
121
+ return { synced };
122
+ }
123
+ export async function syncAll(client, opts) {
124
+ const folders = opts.folders ?? ['inbox', 'sent', 'drafts'];
125
+ const ids = await resolveFolderIds(client);
126
+ const synced = {};
127
+ let unreadInbox = [];
128
+ for (const folder of folders) {
129
+ if (folder === 'inbox') {
130
+ const r = await syncMessageFolder(client, 'inbox', ids.inbox, {
131
+ fetchUnreadBodies: opts.fetchUnreadBodies ?? false,
132
+ });
133
+ synced.inbox = r.synced;
134
+ unreadInbox = r.unread;
135
+ }
136
+ else if (folder === 'sent') {
137
+ const r = await syncMessageFolder(client, 'sent', ids.sent, { fetchUnreadBodies: false });
138
+ synced.sent = r.synced;
139
+ }
140
+ else if (folder === 'drafts') {
141
+ const r = await syncDrafts(client, ids.drafts);
142
+ synced.drafts = r.synced;
143
+ }
144
+ }
145
+ const note = unreadInbox.length > 0
146
+ ? `${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them — this will mark them as read on OFW.`
147
+ : undefined;
148
+ return { synced, unreadInbox, ...(note ? { note } : {}) };
149
+ }