ofw-mcp 2.4.4 → 2.6.3
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +18 -3
- package/dist/auth-password.js +8 -1
- package/dist/bundle.js +1177 -568
- package/dist/cache/node.js +85 -0
- package/dist/cache/store.js +480 -0
- package/dist/client.js +22 -4
- package/dist/config.js +42 -0
- package/dist/index.js +13 -2
- package/dist/ofw-auth.js +26 -0
- package/dist/sync.js +258 -61
- package/dist/tools/_shared.js +23 -6
- package/dist/tools/attachments.js +66 -0
- package/dist/tools/calendar.js +145 -29
- package/dist/tools/messages.js +95 -83
- package/package.json +14 -5
- package/server.json +8 -2
- package/skills/ofw/SKILL.md +2 -0
- package/skills/ofw-fpx/SKILL.md +106 -0
- package/skills/ofw-fpx/references/requests.md +252 -0
- package/dist/cache.js +0 -345
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync, chmodSync, existsSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { OFWCacheCore, LocalCacheStore } from './store.js';
|
|
5
|
+
// The `node:sqlite` backend for the OFW message cache — a local on-disk SQLite
|
|
6
|
+
// file used by the stdio/desktop server. The query logic lives in OFWCacheCore
|
|
7
|
+
// (src/cache/store.ts); this file only adapts `node:sqlite` to the SqlDriver
|
|
8
|
+
// surface and manages the file handle + permissions. (The hosted Cloudflare
|
|
9
|
+
// connector uses a Durable Object backend instead — a later task.)
|
|
10
|
+
/** Adapts a `node:sqlite` DatabaseSync to the driver surface the core needs. */
|
|
11
|
+
export class NodeSqlDriver {
|
|
12
|
+
db;
|
|
13
|
+
constructor(db) {
|
|
14
|
+
this.db = db;
|
|
15
|
+
}
|
|
16
|
+
execScript(sql) {
|
|
17
|
+
this.db.exec(sql);
|
|
18
|
+
}
|
|
19
|
+
run(sql, params) {
|
|
20
|
+
this.db.prepare(sql).run(...params);
|
|
21
|
+
}
|
|
22
|
+
get(sql, params) {
|
|
23
|
+
return this.db.prepare(sql).get(...params);
|
|
24
|
+
}
|
|
25
|
+
all(sql, params) {
|
|
26
|
+
return this.db.prepare(sql).all(...params);
|
|
27
|
+
}
|
|
28
|
+
transaction(fn) {
|
|
29
|
+
this.db.exec('BEGIN');
|
|
30
|
+
try {
|
|
31
|
+
fn();
|
|
32
|
+
this.db.exec('COMMIT');
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
this.db.exec('ROLLBACK');
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// The cache holds full co-parenting message history — keep it private to the
|
|
41
|
+
// owning user. Modes are asserted on every open (not just creation): mkdirSync
|
|
42
|
+
// `mode` and SQLite's default file mode only apply when the path is first
|
|
43
|
+
// created, so a pre-existing dir/db keeps whatever (world-readable) mode it
|
|
44
|
+
// had. The -wal/-shm siblings appear and disappear with WAL checkpoints, hence
|
|
45
|
+
// the existence check.
|
|
46
|
+
export function enforceCachePermissions(dbPath) {
|
|
47
|
+
chmodSync(dirname(dbPath), 0o700);
|
|
48
|
+
chmodSync(dbPath, 0o600);
|
|
49
|
+
for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
50
|
+
if (existsSync(sibling))
|
|
51
|
+
chmodSync(sibling, 0o600);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A file-backed OFW message cache. `open()` creates parent dirs, locks the dir
|
|
56
|
+
* and DB down to 0700/0600, enables WAL, and applies the schema. Pass
|
|
57
|
+
* `:memory:` for an ephemeral in-memory cache (tests) — no dirs or chmod.
|
|
58
|
+
*/
|
|
59
|
+
export class OFWCache extends LocalCacheStore {
|
|
60
|
+
db;
|
|
61
|
+
constructor(db, core) {
|
|
62
|
+
super(core);
|
|
63
|
+
this.db = db;
|
|
64
|
+
}
|
|
65
|
+
static open(path) {
|
|
66
|
+
const memory = path === ':memory:';
|
|
67
|
+
if (!memory)
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
69
|
+
const db = new DatabaseSync(path);
|
|
70
|
+
// First pass: lock down dir + db before WAL siblings exist.
|
|
71
|
+
if (!memory)
|
|
72
|
+
enforceCachePermissions(path);
|
|
73
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
74
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
75
|
+
// Constructing the core applies the schema (and writes the -wal/-shm files).
|
|
76
|
+
const core = new OFWCacheCore(new NodeSqlDriver(db));
|
|
77
|
+
// Second pass: lock down the -wal/-shm the schema writes created.
|
|
78
|
+
if (!memory)
|
|
79
|
+
enforceCachePermissions(path);
|
|
80
|
+
return new OFWCache(db, core);
|
|
81
|
+
}
|
|
82
|
+
close() {
|
|
83
|
+
this.db.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// Storage-agnostic core for the OFW message cache.
|
|
2
|
+
//
|
|
3
|
+
// All message reads (list/get/drafts/unread-sent) are served from this cache;
|
|
4
|
+
// only ofw_sync_messages walks OFW for new content. The SQL lives here ONCE,
|
|
5
|
+
// over a tiny synchronous {@link SqlDriver}, so the same schema/queries back
|
|
6
|
+
// both engines: `node:sqlite` on the stdio/desktop server (src/cache/node.ts)
|
|
7
|
+
// and a Durable Object's SQLite on the hosted Cloudflare connector (a later
|
|
8
|
+
// task). This module imports nothing platform-specific.
|
|
9
|
+
function rowFromDb(r) {
|
|
10
|
+
return {
|
|
11
|
+
id: r.id,
|
|
12
|
+
folder: r.folder,
|
|
13
|
+
subject: r.subject,
|
|
14
|
+
fromUser: r.from_user,
|
|
15
|
+
sentAt: r.sent_at,
|
|
16
|
+
recipients: JSON.parse(r.recipients_json),
|
|
17
|
+
body: r.body,
|
|
18
|
+
fetchedBodyAt: r.fetched_body_at,
|
|
19
|
+
replyToId: r.reply_to_id,
|
|
20
|
+
chainRootId: r.chain_root_id,
|
|
21
|
+
listData: JSON.parse(r.list_data_json),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function draftFromDb(r) {
|
|
25
|
+
return {
|
|
26
|
+
id: r.id,
|
|
27
|
+
subject: r.subject,
|
|
28
|
+
body: r.body,
|
|
29
|
+
recipients: JSON.parse(r.recipients_json),
|
|
30
|
+
replyToId: r.reply_to_id,
|
|
31
|
+
modifiedAt: r.modified_at,
|
|
32
|
+
listData: JSON.parse(r.list_data_json),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function attachmentFromDb(r) {
|
|
36
|
+
return {
|
|
37
|
+
fileId: r.file_id,
|
|
38
|
+
fileName: r.file_name,
|
|
39
|
+
label: r.label,
|
|
40
|
+
mimeType: r.mime_type,
|
|
41
|
+
sizeBytes: r.size_bytes,
|
|
42
|
+
metadata: JSON.parse(r.metadata_json),
|
|
43
|
+
messageIds: JSON.parse(r.message_ids_json),
|
|
44
|
+
downloadedPath: r.downloaded_path,
|
|
45
|
+
downloadedAt: r.downloaded_at,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// node:sqlite rejects `undefined` as a bound parameter ("Provided value cannot
|
|
49
|
+
// be bound"). Normalize undefined to null for nullable columns so callers
|
|
50
|
+
// don't have to remember; throw with a useful error for NOT NULL fields that
|
|
51
|
+
// somehow arrived as undefined.
|
|
52
|
+
function nullish(v) {
|
|
53
|
+
return v === undefined ? null : v;
|
|
54
|
+
}
|
|
55
|
+
function requireString(field, v) {
|
|
56
|
+
if (typeof v === 'string')
|
|
57
|
+
return v;
|
|
58
|
+
throw new Error(`cache: ${field} is required (got ${v === undefined ? 'undefined' : 'null'})`);
|
|
59
|
+
}
|
|
60
|
+
/** Schema statements, split so a driver that only runs one statement per call works. */
|
|
61
|
+
export const SCHEMA_STATEMENTS = [
|
|
62
|
+
`CREATE TABLE IF NOT EXISTS messages (
|
|
63
|
+
id INTEGER PRIMARY KEY,
|
|
64
|
+
folder TEXT NOT NULL,
|
|
65
|
+
subject TEXT NOT NULL,
|
|
66
|
+
from_user TEXT NOT NULL,
|
|
67
|
+
sent_at TEXT NOT NULL,
|
|
68
|
+
recipients_json TEXT NOT NULL,
|
|
69
|
+
body TEXT,
|
|
70
|
+
fetched_body_at TEXT,
|
|
71
|
+
reply_to_id INTEGER,
|
|
72
|
+
chain_root_id INTEGER,
|
|
73
|
+
list_data_json TEXT NOT NULL,
|
|
74
|
+
last_seen_at TEXT NOT NULL
|
|
75
|
+
)`,
|
|
76
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC)`,
|
|
77
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id)`,
|
|
78
|
+
`CREATE TABLE IF NOT EXISTS drafts (
|
|
79
|
+
id INTEGER PRIMARY KEY,
|
|
80
|
+
subject TEXT NOT NULL,
|
|
81
|
+
body TEXT NOT NULL,
|
|
82
|
+
recipients_json TEXT NOT NULL,
|
|
83
|
+
reply_to_id INTEGER,
|
|
84
|
+
modified_at TEXT NOT NULL,
|
|
85
|
+
list_data_json TEXT NOT NULL
|
|
86
|
+
)`,
|
|
87
|
+
`CREATE TABLE IF NOT EXISTS sync_state (
|
|
88
|
+
folder TEXT PRIMARY KEY,
|
|
89
|
+
last_sync_at TEXT NOT NULL,
|
|
90
|
+
newest_id INTEGER
|
|
91
|
+
)`,
|
|
92
|
+
`CREATE TABLE IF NOT EXISTS meta (
|
|
93
|
+
key TEXT PRIMARY KEY,
|
|
94
|
+
value TEXT NOT NULL
|
|
95
|
+
)`,
|
|
96
|
+
// v2: attachments table. Idempotent — IF NOT EXISTS.
|
|
97
|
+
`CREATE TABLE IF NOT EXISTS attachments (
|
|
98
|
+
file_id INTEGER PRIMARY KEY,
|
|
99
|
+
file_name TEXT NOT NULL,
|
|
100
|
+
label TEXT NOT NULL,
|
|
101
|
+
mime_type TEXT NOT NULL,
|
|
102
|
+
size_bytes INTEGER,
|
|
103
|
+
metadata_json TEXT NOT NULL,
|
|
104
|
+
message_ids_json TEXT NOT NULL,
|
|
105
|
+
downloaded_path TEXT,
|
|
106
|
+
downloaded_at TEXT,
|
|
107
|
+
fetched_metadata_at TEXT NOT NULL
|
|
108
|
+
)`,
|
|
109
|
+
];
|
|
110
|
+
/**
|
|
111
|
+
* Idempotent post-schema migrations, applied after {@link SCHEMA_STATEMENTS} on
|
|
112
|
+
* every open. SQLite has no `ADD COLUMN IF NOT EXISTS`, so each statement runs
|
|
113
|
+
* inside a try/catch — re-running against an already-migrated DB throws
|
|
114
|
+
* "duplicate column name", which is swallowed. Driver-agnostic: both
|
|
115
|
+
* `node:sqlite` and the Durable Object's SQLite raise synchronously.
|
|
116
|
+
*/
|
|
117
|
+
export const MIGRATIONS = [
|
|
118
|
+
// Resumable deep-sync cursor. Absent/NULL → SyncState.resumePage null.
|
|
119
|
+
'ALTER TABLE sync_state ADD COLUMN resume_page INTEGER',
|
|
120
|
+
];
|
|
121
|
+
/** The schema version stamped into the `meta` table on open. */
|
|
122
|
+
export const SCHEMA_VERSION = '2';
|
|
123
|
+
// Build the WHERE clause + bound params for message queries. listMessages and
|
|
124
|
+
// countMessages share this so the filter semantics can't drift.
|
|
125
|
+
function buildMessageFilter(opts) {
|
|
126
|
+
const wheres = [];
|
|
127
|
+
const params = [];
|
|
128
|
+
if (opts.folder !== undefined) {
|
|
129
|
+
wheres.push('folder = ?');
|
|
130
|
+
params.push(opts.folder);
|
|
131
|
+
}
|
|
132
|
+
if (opts.since !== undefined) {
|
|
133
|
+
wheres.push('sent_at >= ?');
|
|
134
|
+
params.push(opts.since);
|
|
135
|
+
}
|
|
136
|
+
if (opts.until !== undefined) {
|
|
137
|
+
wheres.push('sent_at < ?');
|
|
138
|
+
params.push(opts.until);
|
|
139
|
+
}
|
|
140
|
+
if (opts.q !== undefined && opts.q.length > 0) {
|
|
141
|
+
const pattern = `%${opts.q}%`;
|
|
142
|
+
wheres.push('(subject LIKE ? OR body LIKE ?)');
|
|
143
|
+
params.push(pattern, pattern);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
where: wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : '',
|
|
147
|
+
params,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The OFW cache logic over a synchronous {@link SqlDriver}. The constructor
|
|
152
|
+
* applies the schema (idempotent CREATE IF NOT EXISTS) and stamps the schema
|
|
153
|
+
* version into the `meta` table.
|
|
154
|
+
*/
|
|
155
|
+
export class OFWCacheCore {
|
|
156
|
+
db;
|
|
157
|
+
constructor(db) {
|
|
158
|
+
this.db = db;
|
|
159
|
+
for (const stmt of SCHEMA_STATEMENTS)
|
|
160
|
+
this.db.execScript(stmt);
|
|
161
|
+
for (const stmt of MIGRATIONS) {
|
|
162
|
+
try {
|
|
163
|
+
this.db.execScript(stmt);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Idempotent: the column already exists on a previously-migrated DB.
|
|
167
|
+
// SQLite lacks ADD COLUMN IF NOT EXISTS, so a re-run throws "duplicate
|
|
168
|
+
// column name" — swallow it and move on.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
this.db.run('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value', ['schema_version', SCHEMA_VERSION]);
|
|
172
|
+
}
|
|
173
|
+
upsertMessage(row) {
|
|
174
|
+
this.db.run(`INSERT INTO messages (
|
|
175
|
+
id, folder, subject, from_user, sent_at, recipients_json,
|
|
176
|
+
body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
|
|
177
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
178
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
179
|
+
folder=excluded.folder,
|
|
180
|
+
subject=excluded.subject,
|
|
181
|
+
from_user=excluded.from_user,
|
|
182
|
+
sent_at=excluded.sent_at,
|
|
183
|
+
recipients_json=excluded.recipients_json,
|
|
184
|
+
body=excluded.body,
|
|
185
|
+
fetched_body_at=excluded.fetched_body_at,
|
|
186
|
+
reply_to_id=excluded.reply_to_id,
|
|
187
|
+
chain_root_id=excluded.chain_root_id,
|
|
188
|
+
list_data_json=excluded.list_data_json,
|
|
189
|
+
last_seen_at=excluded.last_seen_at`, [
|
|
190
|
+
row.id,
|
|
191
|
+
requireString('messages.folder', row.folder),
|
|
192
|
+
requireString('messages.subject', row.subject),
|
|
193
|
+
requireString('messages.fromUser', row.fromUser),
|
|
194
|
+
requireString('messages.sentAt', row.sentAt),
|
|
195
|
+
JSON.stringify(row.recipients ?? []),
|
|
196
|
+
nullish(row.body),
|
|
197
|
+
nullish(row.fetchedBodyAt),
|
|
198
|
+
nullish(row.replyToId),
|
|
199
|
+
nullish(row.chainRootId),
|
|
200
|
+
JSON.stringify(row.listData ?? null),
|
|
201
|
+
new Date().toISOString(),
|
|
202
|
+
]);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Batch upsert every row in a single transaction — one round-trip's worth of
|
|
206
|
+
* work (crucial on the Durable Object backend, where each RPC is a subrequest).
|
|
207
|
+
* Empty array is a no-op (no transaction opened).
|
|
208
|
+
*/
|
|
209
|
+
upsertMessages(rows) {
|
|
210
|
+
if (rows.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
this.db.transaction(() => {
|
|
213
|
+
for (const row of rows)
|
|
214
|
+
this.upsertMessage(row);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
getMessage(id) {
|
|
218
|
+
const r = this.db.get('SELECT * FROM messages WHERE id = ?', [id]);
|
|
219
|
+
return r ? rowFromDb(r) : null;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Batch read: one `SELECT ... WHERE id IN (...)` returning the present rows
|
|
223
|
+
* (absent ids are simply omitted — order is not guaranteed). Empty ids returns
|
|
224
|
+
* `[]` without querying.
|
|
225
|
+
*/
|
|
226
|
+
getMessages(ids) {
|
|
227
|
+
if (ids.length === 0)
|
|
228
|
+
return [];
|
|
229
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
230
|
+
const rows = this.db.all(`SELECT * FROM messages WHERE id IN (${placeholders})`, ids);
|
|
231
|
+
return rows.map(rowFromDb);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Remove a row from the `messages` table. Used by syncDrafts to evict
|
|
235
|
+
* stale rows that were cached when a draft was previously read through
|
|
236
|
+
* `ofw_get_message` (which would have wrongly classified it as `inbox`)
|
|
237
|
+
* — the drafts table is the authoritative source for that id now.
|
|
238
|
+
*/
|
|
239
|
+
deleteMessage(id) {
|
|
240
|
+
this.db.run('DELETE FROM messages WHERE id = ?', [id]);
|
|
241
|
+
}
|
|
242
|
+
listMessages(opts) {
|
|
243
|
+
const { where, params } = buildMessageFilter(opts);
|
|
244
|
+
const offset = (opts.page - 1) * opts.size;
|
|
245
|
+
const rows = this.db.all(`SELECT * FROM messages ${where}
|
|
246
|
+
ORDER BY sent_at DESC, id DESC
|
|
247
|
+
LIMIT ? OFFSET ?`, [...params, opts.size, offset]);
|
|
248
|
+
return rows.map(rowFromDb);
|
|
249
|
+
}
|
|
250
|
+
countMessages(opts) {
|
|
251
|
+
const { where, params } = buildMessageFilter(opts);
|
|
252
|
+
const r = this.db.get(`SELECT COUNT(*) as n FROM messages ${where}`, params);
|
|
253
|
+
/* v8 ignore next -- SELECT COUNT(*) always returns exactly one row; the ?./?? are defensive */
|
|
254
|
+
return r?.n ?? 0;
|
|
255
|
+
}
|
|
256
|
+
upsertDraft(row) {
|
|
257
|
+
this.db.run(`INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
|
|
258
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
259
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
260
|
+
subject=excluded.subject,
|
|
261
|
+
body=excluded.body,
|
|
262
|
+
recipients_json=excluded.recipients_json,
|
|
263
|
+
reply_to_id=excluded.reply_to_id,
|
|
264
|
+
modified_at=excluded.modified_at,
|
|
265
|
+
list_data_json=excluded.list_data_json`, [
|
|
266
|
+
row.id,
|
|
267
|
+
requireString('drafts.subject', row.subject),
|
|
268
|
+
requireString('drafts.body', row.body),
|
|
269
|
+
JSON.stringify(row.recipients ?? []),
|
|
270
|
+
nullish(row.replyToId),
|
|
271
|
+
requireString('drafts.modifiedAt', row.modifiedAt),
|
|
272
|
+
JSON.stringify(row.listData ?? null),
|
|
273
|
+
]);
|
|
274
|
+
}
|
|
275
|
+
/** Batch upsert every draft in a single transaction. Empty array is a no-op. */
|
|
276
|
+
upsertDrafts(rows) {
|
|
277
|
+
if (rows.length === 0)
|
|
278
|
+
return;
|
|
279
|
+
this.db.transaction(() => {
|
|
280
|
+
for (const row of rows)
|
|
281
|
+
this.upsertDraft(row);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
getDraft(id) {
|
|
285
|
+
const r = this.db.get('SELECT * FROM drafts WHERE id = ?', [id]);
|
|
286
|
+
return r ? draftFromDb(r) : null;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Batch read: one `SELECT ... WHERE id IN (...)` returning the present drafts
|
|
290
|
+
* (absent ids omitted — order not guaranteed). Empty ids returns `[]` without
|
|
291
|
+
* querying.
|
|
292
|
+
*/
|
|
293
|
+
getDrafts(ids) {
|
|
294
|
+
if (ids.length === 0)
|
|
295
|
+
return [];
|
|
296
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
297
|
+
const rows = this.db.all(`SELECT * FROM drafts WHERE id IN (${placeholders})`, ids);
|
|
298
|
+
return rows.map(draftFromDb);
|
|
299
|
+
}
|
|
300
|
+
listDrafts(opts) {
|
|
301
|
+
const offset = (opts.page - 1) * opts.size;
|
|
302
|
+
const rows = this.db.all('SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?', [opts.size, offset]);
|
|
303
|
+
return rows.map(draftFromDb);
|
|
304
|
+
}
|
|
305
|
+
deleteDraft(id) {
|
|
306
|
+
this.db.run('DELETE FROM drafts WHERE id = ?', [id]);
|
|
307
|
+
}
|
|
308
|
+
listDraftIds() {
|
|
309
|
+
const rows = this.db.all('SELECT id FROM drafts', []);
|
|
310
|
+
return rows.map((r) => r.id);
|
|
311
|
+
}
|
|
312
|
+
getSyncState(folder) {
|
|
313
|
+
const r = this.db.get('SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?', [folder]);
|
|
314
|
+
if (!r)
|
|
315
|
+
return null;
|
|
316
|
+
// A DB migrated before resume_page existed can still return the row
|
|
317
|
+
// without the column; normalize a missing/NULL value to null.
|
|
318
|
+
return { lastSyncAt: r.last_sync_at, newestId: r.newest_id, resumePage: r.resume_page ?? null };
|
|
319
|
+
}
|
|
320
|
+
setSyncState(folder, state) {
|
|
321
|
+
this.db.run(`INSERT INTO sync_state (folder, last_sync_at, newest_id, resume_page) VALUES (?, ?, ?, ?)
|
|
322
|
+
ON CONFLICT(folder) DO UPDATE SET
|
|
323
|
+
last_sync_at = excluded.last_sync_at,
|
|
324
|
+
newest_id = excluded.newest_id,
|
|
325
|
+
resume_page = excluded.resume_page`, [folder, state.lastSyncAt, nullish(state.newestId), nullish(state.resumePage)]);
|
|
326
|
+
}
|
|
327
|
+
getMeta(key) {
|
|
328
|
+
const r = this.db.get('SELECT value FROM meta WHERE key = ?', [key]);
|
|
329
|
+
return r ? r.value : null;
|
|
330
|
+
}
|
|
331
|
+
setMeta(key, value) {
|
|
332
|
+
this.db.run('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value', [key, value]);
|
|
333
|
+
}
|
|
334
|
+
findLatestReplyTip(replyToId) {
|
|
335
|
+
const parent = this.db.get('SELECT id, folder, chain_root_id FROM messages WHERE id = ?', [replyToId]);
|
|
336
|
+
if (!parent)
|
|
337
|
+
return replyToId;
|
|
338
|
+
const chainRoot = parent.chain_root_id ?? parent.id;
|
|
339
|
+
const tip = this.db.get(`SELECT id FROM messages
|
|
340
|
+
WHERE folder = 'sent' AND chain_root_id = ?
|
|
341
|
+
ORDER BY id DESC LIMIT 1`, [chainRoot]);
|
|
342
|
+
return tip ? tip.id : replyToId;
|
|
343
|
+
}
|
|
344
|
+
getAttachment(fileId) {
|
|
345
|
+
const r = this.db.get('SELECT * FROM attachments WHERE file_id = ?', [fileId]);
|
|
346
|
+
return r ? attachmentFromDb(r) : null;
|
|
347
|
+
}
|
|
348
|
+
listAttachmentsForMessage(messageId) {
|
|
349
|
+
// SQLite JSON1 contains check
|
|
350
|
+
const rows = this.db.all(`SELECT * FROM attachments
|
|
351
|
+
WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
|
|
352
|
+
ORDER BY file_id`, [messageId]);
|
|
353
|
+
return rows.map(attachmentFromDb);
|
|
354
|
+
}
|
|
355
|
+
upsertAttachmentForMessage(input) {
|
|
356
|
+
const existing = this.db.get('SELECT message_ids_json FROM attachments WHERE file_id = ?', [input.fileId]);
|
|
357
|
+
// messageId === 0 is the "metadata-only, not yet linked to a message"
|
|
358
|
+
// sentinel used by upload-without-send and download-by-id. Don't
|
|
359
|
+
// pollute the array with it — leave the list empty / unchanged.
|
|
360
|
+
const prior = existing ? JSON.parse(existing.message_ids_json) : [];
|
|
361
|
+
let messageIds;
|
|
362
|
+
if (input.messageId === 0) {
|
|
363
|
+
messageIds = prior;
|
|
364
|
+
}
|
|
365
|
+
else if (prior.includes(input.messageId)) {
|
|
366
|
+
messageIds = prior;
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
messageIds = [...prior, input.messageId];
|
|
370
|
+
}
|
|
371
|
+
this.db.run(`INSERT INTO attachments (
|
|
372
|
+
file_id, file_name, label, mime_type, size_bytes,
|
|
373
|
+
metadata_json, message_ids_json, fetched_metadata_at
|
|
374
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
375
|
+
ON CONFLICT(file_id) DO UPDATE SET
|
|
376
|
+
file_name=excluded.file_name,
|
|
377
|
+
label=excluded.label,
|
|
378
|
+
mime_type=excluded.mime_type,
|
|
379
|
+
size_bytes=excluded.size_bytes,
|
|
380
|
+
metadata_json=excluded.metadata_json,
|
|
381
|
+
message_ids_json=excluded.message_ids_json,
|
|
382
|
+
fetched_metadata_at=excluded.fetched_metadata_at`, [
|
|
383
|
+
input.fileId,
|
|
384
|
+
requireString('attachments.fileName', input.fileName),
|
|
385
|
+
requireString('attachments.label', input.label),
|
|
386
|
+
requireString('attachments.mimeType', input.mimeType),
|
|
387
|
+
nullish(input.sizeBytes),
|
|
388
|
+
JSON.stringify(input.metadata ?? null),
|
|
389
|
+
JSON.stringify(messageIds),
|
|
390
|
+
new Date().toISOString(),
|
|
391
|
+
]);
|
|
392
|
+
}
|
|
393
|
+
markAttachmentDownloaded(fileId, path) {
|
|
394
|
+
this.db.run('UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?', [
|
|
395
|
+
path,
|
|
396
|
+
new Date().toISOString(),
|
|
397
|
+
fileId,
|
|
398
|
+
]);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Adapts a synchronous {@link OFWCacheCore} to the async {@link CacheStore}
|
|
403
|
+
* interface. Used by the in-process node backend; the Durable Object backend
|
|
404
|
+
* implements CacheStore over a real RPC boundary instead.
|
|
405
|
+
*/
|
|
406
|
+
export class LocalCacheStore {
|
|
407
|
+
core;
|
|
408
|
+
constructor(core) {
|
|
409
|
+
this.core = core;
|
|
410
|
+
}
|
|
411
|
+
async upsertMessage(row) {
|
|
412
|
+
this.core.upsertMessage(row);
|
|
413
|
+
}
|
|
414
|
+
async upsertMessages(rows) {
|
|
415
|
+
this.core.upsertMessages(rows);
|
|
416
|
+
}
|
|
417
|
+
async getMessage(id) {
|
|
418
|
+
return this.core.getMessage(id);
|
|
419
|
+
}
|
|
420
|
+
async getMessages(ids) {
|
|
421
|
+
return this.core.getMessages(ids);
|
|
422
|
+
}
|
|
423
|
+
async deleteMessage(id) {
|
|
424
|
+
this.core.deleteMessage(id);
|
|
425
|
+
}
|
|
426
|
+
async listMessages(opts) {
|
|
427
|
+
return this.core.listMessages(opts);
|
|
428
|
+
}
|
|
429
|
+
async countMessages(opts) {
|
|
430
|
+
return this.core.countMessages(opts);
|
|
431
|
+
}
|
|
432
|
+
async upsertDraft(row) {
|
|
433
|
+
this.core.upsertDraft(row);
|
|
434
|
+
}
|
|
435
|
+
async upsertDrafts(rows) {
|
|
436
|
+
this.core.upsertDrafts(rows);
|
|
437
|
+
}
|
|
438
|
+
async getDraft(id) {
|
|
439
|
+
return this.core.getDraft(id);
|
|
440
|
+
}
|
|
441
|
+
async getDrafts(ids) {
|
|
442
|
+
return this.core.getDrafts(ids);
|
|
443
|
+
}
|
|
444
|
+
async listDrafts(opts) {
|
|
445
|
+
return this.core.listDrafts(opts);
|
|
446
|
+
}
|
|
447
|
+
async deleteDraft(id) {
|
|
448
|
+
this.core.deleteDraft(id);
|
|
449
|
+
}
|
|
450
|
+
async listDraftIds() {
|
|
451
|
+
return this.core.listDraftIds();
|
|
452
|
+
}
|
|
453
|
+
async getSyncState(folder) {
|
|
454
|
+
return this.core.getSyncState(folder);
|
|
455
|
+
}
|
|
456
|
+
async setSyncState(folder, state) {
|
|
457
|
+
this.core.setSyncState(folder, state);
|
|
458
|
+
}
|
|
459
|
+
async getMeta(key) {
|
|
460
|
+
return this.core.getMeta(key);
|
|
461
|
+
}
|
|
462
|
+
async setMeta(key, value) {
|
|
463
|
+
this.core.setMeta(key, value);
|
|
464
|
+
}
|
|
465
|
+
async findLatestReplyTip(replyToId) {
|
|
466
|
+
return this.core.findLatestReplyTip(replyToId);
|
|
467
|
+
}
|
|
468
|
+
async getAttachment(fileId) {
|
|
469
|
+
return this.core.getAttachment(fileId);
|
|
470
|
+
}
|
|
471
|
+
async listAttachmentsForMessage(messageId) {
|
|
472
|
+
return this.core.listAttachmentsForMessage(messageId);
|
|
473
|
+
}
|
|
474
|
+
async upsertAttachmentForMessage(input) {
|
|
475
|
+
this.core.upsertAttachmentForMessage(input);
|
|
476
|
+
}
|
|
477
|
+
async markAttachmentDownloaded(fileId, path) {
|
|
478
|
+
this.core.markAttachmentDownloaded(fileId, path);
|
|
479
|
+
}
|
|
480
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -6,9 +6,17 @@ import { resolveAuth } from './auth.js';
|
|
|
6
6
|
import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
|
|
7
7
|
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
|
|
8
8
|
// bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
|
|
9
|
-
// missing dotenv module
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// missing dotenv module. The try/catch additionally guards the Cloudflare
|
|
10
|
+
// Worker runtime, where `import.meta.url` is undefined and
|
|
11
|
+
// `fileURLToPath(undefined)` would otherwise throw at module init (Worker
|
|
12
|
+
// startup validation) — there is no filesystem / .env to load there anyway.
|
|
13
|
+
try {
|
|
14
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
await loadDotenvSafely({ path: join(dir, '..', '.env') });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* v8 ignore next -- only reached in a non-Node runtime (Workers): no .env to load */
|
|
19
|
+
}
|
|
12
20
|
// Parse a Content-Disposition header for a filename. Prefers RFC 6266
|
|
13
21
|
// `filename*=UTF-8''…` (percent-decoded) and falls back to `filename="…"`.
|
|
14
22
|
function parseContentDispositionFilename(cd) {
|
|
@@ -58,6 +66,16 @@ export class OFWClient {
|
|
|
58
66
|
// already-expired placeholder token so the first request drives the refresh
|
|
59
67
|
// callback — i.e. the original "log in on first request" behavior.
|
|
60
68
|
tokenManager;
|
|
69
|
+
// Optional injected auth resolver. When set, the refresh callback uses it
|
|
70
|
+
// instead of the module-level global `resolveAuth` (env-var → fetchproxy
|
|
71
|
+
// priority). A hosted per-user deployment injects its own resolver so each
|
|
72
|
+
// request carries that user's credentials — see the Cloudflare Worker
|
|
73
|
+
// deployment. Left undefined by the stdio path, which falls back to the
|
|
74
|
+
// global resolver, keeping that behaviour byte-for-byte identical.
|
|
75
|
+
authResolver;
|
|
76
|
+
constructor(opts) {
|
|
77
|
+
this.authResolver = opts?.resolveAuth;
|
|
78
|
+
}
|
|
61
79
|
getTokenManager() {
|
|
62
80
|
if (!this.tokenManager) {
|
|
63
81
|
this.tokenManager = new TokenManager({
|
|
@@ -69,7 +87,7 @@ export class OFWClient {
|
|
|
69
87
|
// path uses (the 401-replay covers a wrong guess). We re-arm the
|
|
70
88
|
// sentinel so the manager can refresh again later.
|
|
71
89
|
refresh: async () => {
|
|
72
|
-
const { token, expiresAt } = await resolveAuth();
|
|
90
|
+
const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
|
|
73
91
|
return {
|
|
74
92
|
accessToken: token,
|
|
75
93
|
refreshToken: OFW_REFRESH_SENTINEL,
|
package/dist/config.js
CHANGED
|
@@ -61,6 +61,27 @@ export function getWriteMode() {
|
|
|
61
61
|
console.error(`[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" — failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`);
|
|
62
62
|
return 'none';
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Calendar-write opt-in for 'drafts' deployments.
|
|
66
|
+
*
|
|
67
|
+
* Messages have a draft stage (the human sends from the web UI), so 'drafts'
|
|
68
|
+
* mode keeps a human between model output and the court-visible record.
|
|
69
|
+
* Calendar events have no draft stage — but unlike a sent message they are
|
|
70
|
+
* fully reversible (editable and deletable), so a drafts-mode user may accept
|
|
71
|
+
* direct calendar writes without accepting sends. Setting
|
|
72
|
+
* OFW_CALENDAR_WRITES=true registers the calendar write tools
|
|
73
|
+
* (ofw_create_event, ofw_update_event, ofw_delete_event) alongside the
|
|
74
|
+
* draft-level message writes.
|
|
75
|
+
*
|
|
76
|
+
* The flag never overrides 'none': that mode is the hard read-only guarantee,
|
|
77
|
+
* including the fail-closed result of an unrecognized OFW_WRITE_MODE.
|
|
78
|
+
*/
|
|
79
|
+
export function getCalendarWritesAllowed() {
|
|
80
|
+
const mode = getWriteMode();
|
|
81
|
+
if (mode === 'all')
|
|
82
|
+
return true;
|
|
83
|
+
return mode === 'drafts' && parseBoolEnv('OFW_CALENDAR_WRITES');
|
|
84
|
+
}
|
|
64
85
|
// Default for ofw_download_attachment's `inline` arg when the caller doesn't
|
|
65
86
|
// pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
|
|
66
87
|
// MCP content blocks by default (skipping disk) — useful on sandboxed MCP
|
|
@@ -68,3 +89,24 @@ export function getWriteMode() {
|
|
|
68
89
|
export function getDefaultInlineAttachments() {
|
|
69
90
|
return parseBoolEnv('OFW_INLINE_ATTACHMENTS');
|
|
70
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Per-invocation OFW-request budget for ofw_sync_messages.
|
|
94
|
+
*
|
|
95
|
+
* The hosted Cloudflare Worker connector enforces a subrequest cap per request
|
|
96
|
+
* (every OFW API fetch and every Durable-Object cache RPC counts), so a deep
|
|
97
|
+
* backfill must be bounded and resumable there. Set OFW_SYNC_MAX_REQUESTS to a
|
|
98
|
+
* positive integer to cap the number of OFW requests one sync call may make
|
|
99
|
+
* before pausing; the next call resumes the walk (deep or not) where it left off.
|
|
100
|
+
*
|
|
101
|
+
* Unset / blank / non-positive / non-integer → POSITIVE_INFINITY, i.e. the
|
|
102
|
+
* local stdio server stays unbounded (walks fully in one call) by default.
|
|
103
|
+
*/
|
|
104
|
+
export function getSyncMaxRequests() {
|
|
105
|
+
const raw = readEnvVar('OFW_SYNC_MAX_REQUESTS');
|
|
106
|
+
if (raw === undefined)
|
|
107
|
+
return Number.POSITIVE_INFINITY;
|
|
108
|
+
const n = Number(raw);
|
|
109
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
110
|
+
return Number.POSITIVE_INFINITY;
|
|
111
|
+
return n;
|
|
112
|
+
}
|