@tiongcreative/assistant-mcp 0.2.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-10-19
4
+
5
+ ### Added
6
+
7
+ - Lists & Notes can now be changed, with Lists & Notes 1.23.0 or later: `lists_and_notes_add_items`, `lists_and_notes_update_items` (tick, untick, reword or annotate items named by their text), `lists_and_notes_add_list`, `lists_and_notes_add_note` and `lists_and_notes_update_note` (append, replace or rename). Changes to an existing list or note send the `modified` stamp last read; if the user changed it in the meantime the app refuses, and the tool re-reads and retries once. Item ids are always sent back. An older Lists & Notes gets a message saying which version writes need. No delete tool. End-to-end test against the real 1.23.0 server.
8
+
3
9
  ## [0.2.0] - 2026-10-19
4
10
 
5
11
  ### Added
package/README.md CHANGED
@@ -10,12 +10,12 @@ nothing else happens.
10
10
 
11
11
  ## Apps
12
12
 
13
- | App | Reads | Changes |
14
- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
15
- | Due Again 1.1.0 or later | items with status and next due date, an item's history | add an item, record that an item was done |
16
- | Gift Keeper 1.3.0 or later | people with interests, sizes and preferences; upcoming occasions with budget, spend and whether a gift is chosen | add a gift idea for a person, mark a gift as given |
17
- | Client Manager 1.6.0 or later | pending reminders, open tasks, invoices owing with totals and balance, one client with their jobs | add a client, a job, a reminder or a task, change a job, task or reminder, log an activity |
18
- | Lists & Notes 1.22.0 or later | lists with their items, notes with previews, the full text of a note | nothing yet |
13
+ | App | Reads | Changes |
14
+ | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
15
+ | Due Again 1.1.0 or later | items with status and next due date, an item's history | add an item, record that an item was done |
16
+ | Gift Keeper 1.3.0 or later | people with interests, sizes and preferences; upcoming occasions with budget, spend and whether a gift is chosen | add a gift idea for a person, mark a gift as given |
17
+ | Client Manager 1.6.0 or later | pending reminders, open tasks, invoices owing with totals and balance, one client with their jobs | add a client, a job, a reminder or a task, change a job, task or reminder, log an activity |
18
+ | Lists & Notes 1.22.0 or later (changes need 1.23.0) | lists with their items, notes with previews, the full text of a note | add a list or a note, add items, tick off or reword items, add to or rename a note |
19
19
 
20
20
  More apps follow, and the assistant can also open an app for you, but only after you agree.
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiongcreative/assistant-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server that lets an AI assistant on your Mac use your Tiong Creative apps while they are open.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,9 +1,12 @@
1
- // Lists & Notes: checklists and markdown notes. Read-only for now: the app's list
2
- // endpoint replaces the whole file from the request, so writes wait until that is
3
- // hardened (see the plan). Talks to the app's local API with the per-launch token it
4
- // writes while "Allow assistant access" is on (list-manager/assistant-access.js).
1
+ // Lists & Notes: checklists and markdown notes. Writes need Lists & Notes 1.23.0 or later,
2
+ // which gives the assistant its own per-list and per-note routes (the whole-file save is
3
+ // window-only). Changing an existing list or note sends the `modified` stamp last read
4
+ // (`baseModified`); if the user changed it since, the app refuses and the tool re-reads and
5
+ // retries once. Item ids are always sent back, or the app would treat items as new. There
6
+ // is no delete tool. Talks to the app's local API with the per-launch token it writes while
7
+ // "Allow assistant access" is on (list-manager/assistant-access.js).
5
8
  import { z } from 'zod';
6
- import { appConnection, apiCall, notAvailable } from '../connection.js';
9
+ import { ApiError, appConnection, apiCall, notAvailable } from '../connection.js';
7
10
 
8
11
  export const app = {
9
12
  id: 'lists-and-notes',
@@ -47,6 +50,63 @@ function matches(text, q) {
47
50
  return typeof text === 'string' && text.toLowerCase().includes(q);
48
51
  }
49
52
 
53
+ const WRITES_FROM = '1.23.0';
54
+
55
+ function olderThan(version, min) {
56
+ const a = String(version ?? '0')
57
+ .split('.')
58
+ .map(Number);
59
+ const b = min.split('.').map(Number);
60
+ for (let i = 0; i < 3; i++) {
61
+ if ((a[i] || 0) !== b[i]) return (a[i] || 0) < b[i];
62
+ }
63
+ return false;
64
+ }
65
+
66
+ /** The app's version must support writes; returns a result to send back, or null. */
67
+ function needsUpdate(conn) {
68
+ if (!olderThan(conn.version, WRITES_FROM)) return null;
69
+ return {
70
+ app: app.name,
71
+ changed: false,
72
+ message: `Changing lists and notes needs Lists & Notes ${WRITES_FROM} or later (this Mac has ${conn.version ?? 'an older version'}). Reading still works.`,
73
+ };
74
+ }
75
+
76
+ async function findList(conn, name) {
77
+ const lists = await apiCall(conn, 'GET', '/api/data/lists');
78
+ const wanted = name.trim().toLowerCase();
79
+ return {
80
+ list: lists.find((l) => l.name.toLowerCase() === wanted),
81
+ names: lists.map((l) => l.name),
82
+ };
83
+ }
84
+
85
+ async function findNote(conn, title) {
86
+ const notes = await apiCall(conn, 'GET', '/api/data/notes');
87
+ const wanted = title.trim().toLowerCase();
88
+ return {
89
+ note: notes.find((n) => n.title.toLowerCase() === wanted),
90
+ titles: notes.map((n) => n.title),
91
+ };
92
+ }
93
+
94
+ /** Runs a change against the latest copy; on a 409 (changed in the window) re-reads once. */
95
+ async function withRetry(load, change) {
96
+ let current = await load();
97
+ try {
98
+ return await change(current);
99
+ } catch (err) {
100
+ if (!(err instanceof ApiError) || err.status !== 409) throw err;
101
+ current = await load();
102
+ return change(current);
103
+ }
104
+ }
105
+
106
+ function itemMatches(item, text) {
107
+ return item.text.trim().toLowerCase() === text.trim().toLowerCase();
108
+ }
109
+
50
110
  export const tools = [
51
111
  {
52
112
  name: 'lists_and_notes_lists',
@@ -156,6 +216,248 @@ export const tools = [
156
216
  };
157
217
  },
158
218
  },
219
+ {
220
+ name: 'lists_and_notes_add_items',
221
+ description:
222
+ 'Add items to an existing list in Lists & Notes, by list name. Items are added unticked at the end. If the list does not exist, the reply lists the lists.',
223
+ input: z.object({
224
+ list: z.string().min(1).max(75),
225
+ items: z.array(z.string().trim().min(1).max(2000)).min(1).max(100),
226
+ }),
227
+ write: true,
228
+ async run(input) {
229
+ const conn = await appConnection(app);
230
+ if (!conn.ok) return notAvailable(app, conn);
231
+ const old = needsUpdate(conn);
232
+ if (old) return old;
233
+ const { list, names } = await findList(conn, input.list);
234
+ if (!list)
235
+ return {
236
+ app: app.name,
237
+ changed: false,
238
+ message: `No list called "${input.list}".`,
239
+ lists: names,
240
+ };
241
+ const updated = await apiCall(
242
+ conn,
243
+ 'POST',
244
+ `/api/data/lists/${encodeURIComponent(list.id)}/items`,
245
+ {
246
+ items: input.items.map((text) => ({ text })),
247
+ },
248
+ );
249
+ return { app: app.name, changed: true, list: summariseList(updated), added: input.items };
250
+ },
251
+ },
252
+ {
253
+ name: 'lists_and_notes_update_items',
254
+ description:
255
+ "Tick off, untick, reword or add notes to items on a list in Lists & Notes. Name the list and each item by its current text. Items not mentioned are left as they are. If an item's text does not match, the reply lists the items.",
256
+ input: z.object({
257
+ list: z.string().min(1).max(75),
258
+ changes: z
259
+ .array(
260
+ z.object({
261
+ item: z.string().min(1).max(2000).describe('The item as it reads now'),
262
+ done: z.boolean().optional(),
263
+ text: z.string().trim().min(1).max(2000).optional().describe('New wording'),
264
+ notes: z.string().max(2000).optional(),
265
+ }),
266
+ )
267
+ .min(1)
268
+ .max(100),
269
+ }),
270
+ write: true,
271
+ async run(input) {
272
+ const conn = await appConnection(app);
273
+ if (!conn.ok) return notAvailable(app, conn);
274
+ const old = needsUpdate(conn);
275
+ if (old) return old;
276
+ const first = await findList(conn, input.list);
277
+ if (!first.list)
278
+ return {
279
+ app: app.name,
280
+ changed: false,
281
+ message: `No list called "${input.list}".`,
282
+ lists: first.names,
283
+ };
284
+ const id = first.list.id;
285
+ const load = async () =>
286
+ (await apiCall(conn, 'GET', '/api/data/lists')).find((l) => l.id === id);
287
+ return withRetry(load, async (list) => {
288
+ if (!list)
289
+ return { app: app.name, changed: false, message: 'That list has just been removed.' };
290
+ const items = (list.items ?? []).map((i) => ({ ...i }));
291
+ const missing = input.changes
292
+ .filter((c) => !items.some((i) => itemMatches(i, c.item)))
293
+ .map((c) => c.item);
294
+ if (missing.length) {
295
+ return {
296
+ app: app.name,
297
+ changed: false,
298
+ message: `No item reading ${missing.map((m) => `"${m}"`).join(', ')} on ${list.name}.`,
299
+ items: items.map((i) => i.text),
300
+ };
301
+ }
302
+ for (const c of input.changes) {
303
+ const item = items.find((i) => itemMatches(i, c.item));
304
+ if (c.done !== undefined) item.completed = c.done;
305
+ if (c.text !== undefined) item.text = c.text;
306
+ if (c.notes !== undefined) item.notes = c.notes;
307
+ }
308
+ const updated = await apiCall(conn, 'PATCH', `/api/data/lists/${encodeURIComponent(id)}`, {
309
+ items,
310
+ baseModified: list.metadata?.modified,
311
+ });
312
+ return { app: app.name, changed: true, list: summariseList(updated) };
313
+ });
314
+ },
315
+ },
316
+ {
317
+ name: 'lists_and_notes_add_list',
318
+ description:
319
+ 'Create a new list in Lists & Notes with optional first items. The category must already exist in the app; if it does not, the reply lists the categories.',
320
+ input: z.object({
321
+ name: z.string().trim().min(1).max(75),
322
+ items: z.array(z.string().trim().min(1).max(2000)).max(100).optional(),
323
+ category: z.string().max(100).optional().describe('Defaults to personal'),
324
+ tags: z.array(z.string().max(50)).max(10).optional(),
325
+ }),
326
+ write: true,
327
+ async run(input) {
328
+ const conn = await appConnection(app);
329
+ if (!conn.ok) return notAvailable(app, conn);
330
+ const old = needsUpdate(conn);
331
+ if (old) return old;
332
+ const [{ list: existing }, categories] = await Promise.all([
333
+ findList(conn, input.name),
334
+ apiCall(conn, 'GET', '/api/data/categories'),
335
+ ]);
336
+ if (existing)
337
+ return {
338
+ app: app.name,
339
+ added: false,
340
+ message: `There is already a list called "${input.name}".`,
341
+ };
342
+ const category = input.category
343
+ ? categories.find((c) => c.toLowerCase() === input.category.trim().toLowerCase())
344
+ : 'personal';
345
+ if (!category)
346
+ return {
347
+ app: app.name,
348
+ added: false,
349
+ message: `No category called "${input.category}".`,
350
+ categories,
351
+ };
352
+ const list = await apiCall(conn, 'POST', '/api/data/lists/add', {
353
+ name: input.name,
354
+ category,
355
+ ...(input.tags ? { tags: input.tags } : {}),
356
+ items: (input.items ?? []).map((text) => ({ text, completed: false, notes: '' })),
357
+ });
358
+ return { app: app.name, added: true, list: summariseList(list) };
359
+ },
360
+ },
361
+ {
362
+ name: 'lists_and_notes_add_note',
363
+ description:
364
+ 'Create a note in Lists & Notes with a title and markdown content. The category must already exist; if it does not, the reply lists the categories.',
365
+ input: z.object({
366
+ title: z.string().trim().min(1).max(75),
367
+ content: z.string().max(200000).default(''),
368
+ category: z.string().max(100).optional().describe('Defaults to personal'),
369
+ tags: z.array(z.string().max(50)).max(10).optional(),
370
+ }),
371
+ write: true,
372
+ async run(input) {
373
+ const conn = await appConnection(app);
374
+ if (!conn.ok) return notAvailable(app, conn);
375
+ const old = needsUpdate(conn);
376
+ if (old) return old;
377
+ const categories = await apiCall(conn, 'GET', '/api/data/categories');
378
+ const category = input.category
379
+ ? categories.find((c) => c.toLowerCase() === input.category.trim().toLowerCase())
380
+ : 'personal';
381
+ if (!category)
382
+ return {
383
+ app: app.name,
384
+ added: false,
385
+ message: `No category called "${input.category}".`,
386
+ categories,
387
+ };
388
+ const note = await apiCall(conn, 'POST', '/api/data/notes', {
389
+ title: input.title,
390
+ category,
391
+ content: input.content,
392
+ ...(input.tags ? { tags: input.tags } : {}),
393
+ });
394
+ return { app: app.name, added: true, note: summariseNote(note) };
395
+ },
396
+ },
397
+ {
398
+ name: 'lists_and_notes_update_note',
399
+ description:
400
+ "Change a note in Lists & Notes, by title: add text to the end (append), replace its text, or rename it. If the user edits the note at the same moment, the change is applied to their latest version; the app keeps the previous text in the note's history.",
401
+ input: z.object({
402
+ title: z.string().min(1).max(75).describe('The note as it is titled now'),
403
+ append: z.string().max(200000).optional().describe('Text added on a new line at the end'),
404
+ content: z.string().max(200000).optional().describe('Replaces the whole text'),
405
+ newTitle: z.string().trim().min(1).max(75).optional(),
406
+ }),
407
+ write: true,
408
+ async run(input) {
409
+ const conn = await appConnection(app);
410
+ if (!conn.ok) return notAvailable(app, conn);
411
+ const old = needsUpdate(conn);
412
+ if (old) return old;
413
+ if (
414
+ input.append === undefined &&
415
+ input.content === undefined &&
416
+ input.newTitle === undefined
417
+ ) {
418
+ return {
419
+ app: app.name,
420
+ changed: false,
421
+ message: 'Nothing to change: give text to append, new content or a new title.',
422
+ };
423
+ }
424
+ if (input.append !== undefined && input.content !== undefined) {
425
+ return {
426
+ app: app.name,
427
+ changed: false,
428
+ message: 'Give either append or content, not both.',
429
+ };
430
+ }
431
+ const first = await findNote(conn, input.title);
432
+ if (!first.note)
433
+ return {
434
+ app: app.name,
435
+ changed: false,
436
+ message: `No note called "${input.title}".`,
437
+ titles: first.titles,
438
+ };
439
+ const id = first.note.id;
440
+ const load = () => apiCall(conn, 'GET', `/api/data/notes/${encodeURIComponent(id)}`);
441
+ return withRetry(load, async (note) => {
442
+ const body = { baseModified: note.metadata?.modified };
443
+ if (input.content !== undefined) body.content = input.content;
444
+ if (input.append !== undefined) {
445
+ const current = typeof note.content === 'string' ? note.content : '';
446
+ body.content = current
447
+ ? `${current.replace(/\s+$/, '')}\n\n${input.append}`
448
+ : input.append;
449
+ }
450
+ if (input.newTitle !== undefined) body.title = input.newTitle;
451
+ const updated = await apiCall(
452
+ conn,
453
+ 'PUT',
454
+ `/api/data/notes/${encodeURIComponent(id)}`,
455
+ body,
456
+ );
457
+ return { app: app.name, changed: true, note: summariseNote(updated) };
458
+ });
459
+ },
460
+ },
159
461
  ];
160
462
 
161
463
  /** Lists & Notes has no dated items, so it adds nothing to "what's on". */