rootr-cli 0.8.1 → 0.10.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.
@@ -13,6 +13,61 @@ import {
13
13
  /** Human text for the `databaseId` param — accepts a path just like the document tools. */
14
14
  const DATABASE_TARGET_DESC = `DATABASE node id${NODE_TARGET_HINT} (e.g. "/제작 공정/칸반")`;
15
15
 
16
+ /**
17
+ * VIEW-FILTER — 사람이 화면에서 거는 "이 조건만 보이게"를 에이전트도 걸 수 있게 한다.
18
+ * 조건은 전부 만족해야 하고(AND), 한 조건 안의 `values`는 그중 하나만 맞아도 된다(OR).
19
+ */
20
+ const FILTER_SHAPE = z.array(
21
+ z.object({
22
+ propId: z.string().describe('Column NAME or id the condition applies to'),
23
+ op: z
24
+ .enum([
25
+ 'is',
26
+ 'is_not',
27
+ 'contains',
28
+ 'is_empty',
29
+ 'is_not_empty',
30
+ 'today',
31
+ 'this_week',
32
+ 'this_month',
33
+ 'past',
34
+ 'upcoming',
35
+ 'before',
36
+ 'after',
37
+ 'on',
38
+ 'between',
39
+ 'eq',
40
+ 'gt',
41
+ 'lt',
42
+ 'checked',
43
+ 'unchecked',
44
+ ])
45
+ .describe('Comparison to apply'),
46
+ values: z
47
+ .array(z.string())
48
+ .optional()
49
+ .describe(
50
+ 'Values the op needs. is/is_not/contains → one or more (OR between them). ' +
51
+ 'before/after/on → one date "YYYY-MM-DD". between → two dates. eq/gt/lt → one number. ' +
52
+ 'is_empty/is_not_empty/today/this_week/this_month/past/upcoming/checked/unchecked take none.',
53
+ ),
54
+ }),
55
+ );
56
+
57
+ const FILTER_DESC =
58
+ 'Only rows matching EVERY condition are shown in this view (AND between conditions, OR inside one ' +
59
+ 'condition\'s values). This is the same filter a person sets on screen, so "검토 대기인 것만 보이게 해줘" ' +
60
+ 'is expressible: [{"propId":"구분","op":"is","values":["검토 대기"]}]. Rows are NOT deleted — other views ' +
61
+ 'still show them. A new row added while a filter is on is seeded with the filtered values so it does not ' +
62
+ 'vanish the moment it is created.';
63
+
64
+ const SORT_SHAPE = z.object({
65
+ propId: z.string().describe('Column NAME or id to sort by'),
66
+ dir: z.enum(['asc', 'desc']).describe('asc = 오름차순, desc = 내림차순'),
67
+ });
68
+
69
+ const SORT_DESC = 'Row order in this view.';
70
+
16
71
  /**
17
72
  * DB-BODY — 행의 상세 내용(본문 메모). 화면에서 칸반 카드를 열면 속성 아래에 붙는 그 글이며,
18
73
  * 실체는 표 밑에 달리는 문서 하나다. 예전에는 REST 2단계로만 닿을 수 있어서 CLI·MCP에서는
@@ -60,11 +115,17 @@ export function registerDatabaseTools(server) {
60
115
  'board, a task/requirement/risk tracker, anything with trackable rows. Do NOT fake a table with a markdown ' +
61
116
  'document, and do NOT create an empty database and then add columns one by one — `properties` order IS the ' +
62
117
  'column order, so one call keeps the columns in exactly the order you list them. ' +
63
- 'The FIRST property must be type "title" (the row label). Other types: text | number | select | ' +
64
- 'multi_select | date | checkbox | url | person. select/multi_select take options:[{name,color}]. ' +
65
- 'Views default to a single table view; a "board" view needs config.groupBy = a select property id, a ' +
66
- '"timeline" view needs config.dateProp. Row `values` are keyed by column NAME (or id); date values are ' +
67
- '{ start: "YYYY-MM-DD", end? }, multi_select is string[].',
118
+ 'The FIRST property must be type "title" (the row label). Other types: text | number | select | multi_select | ' +
119
+ 'status | date | checkbox | url | email | phone | person | files | relation | formula | rollup | ' +
120
+ 'created_time | last_edited_time | created_by | last_edited_by | unique_id. ' +
121
+ 'select/multi_select/status take options:[{name,color,group?}] (group ∈ todo|in_progress|done, status only); ' +
122
+ 'relation takes relationDatabaseId; formula takes formula ("{수량} * {단가}"); rollup takes ' +
123
+ 'rollup:{relation,property?,function}; unique_id takes prefix ("TASK" → "TASK-42"). The last seven types are ' +
124
+ 'filled in by the server and take no row value. ' +
125
+ 'Views default to a single table view; view types are table | board | gallery | calendar | timeline | list — ' +
126
+ 'a "board" view needs config.groupBy (a select/status property id), a "timeline"/"calendar" view needs ' +
127
+ 'config.dateProp. Row `values` are keyed by column NAME (or id); date values are ' +
128
+ '{ start: "YYYY-MM-DDTHH:mm" or "YYYY-MM-DD", end? }, multi_select is string[], relation is an array of row ids.',
68
129
  annotations: { readOnlyHint: false, destructiveHint: false },
69
130
  inputSchema: {
70
131
  name: z.string().describe('Table name, e.g. "업무 목록"'),
@@ -77,12 +138,54 @@ export function registerDatabaseTools(server) {
77
138
  z.object({
78
139
  name: z.string().describe('Column header, e.g. "담당자"'),
79
140
  type: z
80
- .enum(['title', 'text', 'number', 'select', 'multi_select', 'date', 'checkbox', 'url', 'person'])
141
+ .enum([
142
+ 'title',
143
+ 'text',
144
+ 'number',
145
+ 'select',
146
+ 'multi_select',
147
+ 'status',
148
+ 'date',
149
+ 'checkbox',
150
+ 'url',
151
+ 'email',
152
+ 'phone',
153
+ 'person',
154
+ 'files',
155
+ 'relation',
156
+ 'formula',
157
+ 'rollup',
158
+ 'created_time',
159
+ 'last_edited_time',
160
+ 'created_by',
161
+ 'last_edited_by',
162
+ 'unique_id',
163
+ ])
81
164
  .describe('Column type; the FIRST property must be "title"'),
82
165
  options: z
83
- .array(z.object({ name: z.string(), color: z.string() }))
166
+ .array(z.object({ name: z.string(), color: z.string(), group: z.string().optional() }))
167
+ .optional()
168
+ .describe('select/multi_select/status only: the choices'),
169
+ relationDatabaseId: z.string().optional().describe('relation only: the table this column points at'),
170
+ formula: z.string().optional().describe('formula only: e.g. "{수량} * {단가}"'),
171
+ rollup: z
172
+ .object({
173
+ relation: z.string(),
174
+ property: z.string().optional(),
175
+ function: z.enum([
176
+ 'count',
177
+ 'count_values',
178
+ 'sum',
179
+ 'average',
180
+ 'min',
181
+ 'max',
182
+ 'show_original',
183
+ 'percent_checked',
184
+ ]),
185
+ })
84
186
  .optional()
85
- .describe('select/multi_select only: the choices'),
187
+ .describe('rollup only: what to gather through which relation'),
188
+ prefix: z.string().optional().describe('unique_id only: e.g. "TASK" → "TASK-42"'),
86
189
  }),
87
190
  )
88
191
  .optional()
@@ -95,10 +198,18 @@ export function registerDatabaseTools(server) {
95
198
  .array(z.object({ values: z.record(z.unknown()) }))
96
199
  .optional()
97
200
  .describe('Initial rows, e.g. [{ values: { "이름": "킥오프", "기한": { "start": "2026-08-01" } } }]'),
201
+ rowPageType: z
202
+ .enum(['DOCUMENT', 'SIGNED'])
203
+ .optional()
204
+ .describe(
205
+ 'SIGN-001 — what a row\'s detail page is. "SIGNED" makes every row open as a 서명문서/연구노트 ' +
206
+ '(freezes on submit, ordered signatures + RFC 3161 timestamps, undeletable). Use it for 실험 기록 · ' +
207
+ '점검 대장 · 결재 대장 표. Default "DOCUMENT".',
208
+ ),
98
209
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
99
210
  },
100
211
  },
101
- async ({ name, parentId, path, createParents, icon, properties, views, rows, workspace }) => {
212
+ async ({ name, parentId, path, createParents, icon, properties, views, rows, rowPageType, workspace }) => {
102
213
  try {
103
214
  const ws = requireWorkspace(workspace);
104
215
  const client = makeClient(ws);
@@ -112,6 +223,7 @@ export function registerDatabaseTools(server) {
112
223
  properties,
113
224
  views,
114
225
  rows,
226
+ rowPageType,
115
227
  });
116
228
  return jsonResult(database);
117
229
  } catch (err) {
@@ -120,6 +232,84 @@ export function registerDatabaseTools(server) {
120
232
  },
121
233
  );
122
234
 
235
+ server.registerTool(
236
+ 'rootr_create_view',
237
+ {
238
+ title: 'Add a view to an existing database',
239
+ description:
240
+ 'Add ONE more view (table / board / gallery / calendar / timeline) to a Rootr (루터) DATABASE that ' +
241
+ 'already exists. Until now views could only be set when the table was CREATED, so a Notion table with ' +
242
+ 'four views could only be migrated with one. Existing views are untouched — the server merges, unlike ' +
243
+ 'a full PATCH which replaces the whole views array and silently drops anything you leave out. ' +
244
+ 'A board needs `groupBy` (a select column); calendar/timeline need `dateProp`. Columns may be given ' +
245
+ 'by NAME or id.',
246
+ annotations: { readOnlyHint: false, destructiveHint: false },
247
+ inputSchema: {
248
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
249
+ name: z.string().describe('View name shown on the tab, e.g. "일정"'),
250
+ type: z
251
+ .enum(['table', 'board', 'gallery', 'calendar', 'timeline'])
252
+ .describe('View layout'),
253
+ groupBy: z.string().optional().describe('board view: the select column to group by (name or id)'),
254
+ dateProp: z.string().optional().describe('calendar/timeline view: the date column (name or id)'),
255
+ endDateProp: z.string().optional().describe('timeline view: the end-date column (name or id)'),
256
+ filters: FILTER_SHAPE.optional().describe(FILTER_DESC),
257
+ sort: SORT_SHAPE.optional().describe(SORT_DESC),
258
+ colWidths: z
259
+ .record(z.number())
260
+ .optional()
261
+ .describe(
262
+ 'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
263
+ '40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
264
+ ),
265
+ },
266
+ },
267
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths }) => {
268
+ try {
269
+ const client = makeClient();
270
+ const id = await resolveNodeId(client, databaseId);
271
+ const view = await client.createDatabaseView(id, {
272
+ name,
273
+ type,
274
+ groupBy,
275
+ dateProp,
276
+ endDateProp,
277
+ filters,
278
+ sort,
279
+ colWidths,
280
+ });
281
+ return jsonResult(view);
282
+ } catch (err) {
283
+ return errorResult(err);
284
+ }
285
+ },
286
+ );
287
+
288
+ server.registerTool(
289
+ 'rootr_delete_view',
290
+ {
291
+ title: 'Delete one view from a database',
292
+ description:
293
+ 'Remove ONE view from a Rootr (루터) DATABASE. Rows and column values are NOT touched — only that ' +
294
+ 'view\'s layout is removed. The last remaining view cannot be deleted (there would be nothing to look at).',
295
+ annotations: { readOnlyHint: false, destructiveHint: true },
296
+ inputSchema: {
297
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
298
+ viewId: z.string().describe('View id or view NAME (from rootr_read_database)'),
299
+ },
300
+ },
301
+ async ({ databaseId, viewId }) => {
302
+ try {
303
+ const client = makeClient();
304
+ const id = await resolveNodeId(client, databaseId);
305
+ await client.deleteDatabaseView(id, viewId);
306
+ return jsonResult({ deleted: viewId });
307
+ } catch (err) {
308
+ return errorResult(err);
309
+ }
310
+ },
311
+ );
312
+
123
313
  server.registerTool(
124
314
  'rootr_update_view',
125
315
  {
@@ -142,9 +332,29 @@ export function registerDatabaseTools(server) {
142
332
  groupBy: z.string().optional().describe('board view: the select column to group by (name or id)'),
143
333
  dateProp: z.string().optional().describe('calendar/timeline view: the date column (name or id)'),
144
334
  endDateProp: z.string().optional().describe('timeline view: the end-date column (name or id)'),
335
+ filters: FILTER_SHAPE.nullable().optional().describe(FILTER_DESC + ' null 또는 []을 주면 조건을 전부 없앤다.'),
336
+ sort: SORT_SHAPE.nullable().optional().describe(SORT_DESC + ' null을 주면 정렬을 끈다.'),
337
+ colWidths: z
338
+ .record(z.number())
339
+ .optional()
340
+ .describe(
341
+ 'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
342
+ '40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
343
+ ),
145
344
  },
146
345
  },
147
- async ({ databaseId, viewId, hiddenProps, name, groupBy, dateProp, endDateProp }) => {
346
+ async ({
347
+ databaseId,
348
+ viewId,
349
+ hiddenProps,
350
+ name,
351
+ groupBy,
352
+ dateProp,
353
+ endDateProp,
354
+ filters,
355
+ sort,
356
+ colWidths,
357
+ }) => {
148
358
  try {
149
359
  const client = makeClient();
150
360
  const id = await resolveNodeId(client, databaseId);
@@ -154,6 +364,9 @@ export function registerDatabaseTools(server) {
154
364
  groupBy,
155
365
  dateProp,
156
366
  endDateProp,
367
+ filters,
368
+ sort,
369
+ colWidths,
157
370
  });
158
371
  return jsonResult({ view });
159
372
  } catch (err) {
@@ -229,7 +442,8 @@ export function registerDatabaseTools(server) {
229
442
  title: 'Add a database row',
230
443
  description:
231
444
  'Append a row to a Rootr (루터) DATABASE. `values` maps property NAME (or id) → value. ' +
232
- 'Value shapes by type: title/text/url/select/person → string; number → number; multi_select → string[]; ' +
445
+ 'Value shapes by type: title/text/url/select → string; number → number; multi_select → string[]; ' +
446
+ 'person → string[] (a LIST of names — a meeting has several attendees; one bare name is also accepted); ' +
233
447
  'checkbox → boolean; date → { start: "YYYY-MM-DD", end? }. A select value is the OPTION NAME. ' +
234
448
  'Call rootr_read_database first to learn the columns. Optionally set `position` to insert at an index. ' +
235
449
  'Pass `body` to also write the row\'s DETAIL PAGE (the note shown under a kanban card\'s properties) — ' +
@@ -328,27 +542,91 @@ export function registerDatabaseTools(server) {
328
542
  description:
329
543
  'Add ONE column to a Rootr (루터) DATABASE. The existing columns and every row are left untouched — ' +
330
544
  'rows simply read empty for the new column until you fill them with rootr_update_row. ' +
331
- 'type ∈ text | number | select | multi_select | date | checkbox | url | person ' +
545
+ 'type ∈ text | number | select | multi_select | status | date | checkbox | url | email | phone | person | files | ' +
546
+ 'relation | formula | rollup | created_time | last_edited_time | created_by | last_edited_by | unique_id ' +
332
547
  '("title" is not allowed: a database already has exactly one title column). ' +
333
- 'For select/multi_select pass options: [{ name, color }]. Returns the created column WITH ITS id.',
548
+ 'For select/multi_select/status pass options: [{ name, color, group? }] (group ∈ todo|in_progress|done, status only). ' +
549
+ 'For relation pass relationDatabaseId (the node id of the table it points at). ' +
550
+ 'For formula pass formula, e.g. "{수량} * {단가}" or "if({금액} >= 5000, \\"큰 건\\", \\"작은 건\\")" — ' +
551
+ 'available functions: if, concat, length, upper, lower, contains, abs, round, floor, ceil, min, max, dateBetween, now, empty. ' +
552
+ 'For rollup pass rollup: { relation, property?, function } (function ∈ count|count_values|sum|average|min|max|show_original|percent_checked). ' +
553
+ 'For unique_id pass prefix to get "TASK-42" style ids. ' +
554
+ 'created_time / last_edited_time / created_by / last_edited_by / unique_id / formula / rollup are filled in by the server — ' +
555
+ 'they take no value and rootr_update_row will refuse to set them. ' +
556
+ 'Returns the created column WITH ITS id.',
334
557
  annotations: { readOnlyHint: false, destructiveHint: false },
335
558
  inputSchema: {
336
559
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
337
560
  name: z.string().describe('Column header, e.g. "담당자"'),
338
561
  type: z
339
- .enum(['text', 'number', 'select', 'multi_select', 'date', 'checkbox', 'url', 'person'])
562
+ .enum([
563
+ 'text',
564
+ 'number',
565
+ 'select',
566
+ 'multi_select',
567
+ 'status',
568
+ 'date',
569
+ 'checkbox',
570
+ 'url',
571
+ 'email',
572
+ 'phone',
573
+ 'person',
574
+ 'files',
575
+ 'relation',
576
+ 'formula',
577
+ 'rollup',
578
+ 'created_time',
579
+ 'last_edited_time',
580
+ 'created_by',
581
+ 'last_edited_by',
582
+ 'unique_id',
583
+ ])
340
584
  .describe('Column type'),
341
585
  options: z
342
- .array(z.object({ name: z.string(), color: z.string() }))
586
+ .array(z.object({ name: z.string(), color: z.string(), group: z.string().optional() }))
587
+ .optional()
588
+ .describe('select/multi_select/status only: the choices, e.g. [{ name: "진행중", color: "blue" }]'),
589
+ relationDatabaseId: z
590
+ .string()
591
+ .optional()
592
+ .describe('relation only: the node id of the database this column points at'),
593
+ formula: z
594
+ .string()
343
595
  .optional()
344
- .describe('select/multi_select only: the choices, e.g. [{ name: "진행중", color: "blue" }]'),
596
+ .describe('formula only: the expression, e.g. "{수량} * {단가}"'),
597
+ rollup: z
598
+ .object({
599
+ relation: z.string().describe('the relation column (id or name) to follow'),
600
+ property: z.string().optional().describe('the column to read in the linked table'),
601
+ function: z.enum([
602
+ 'count',
603
+ 'count_values',
604
+ 'sum',
605
+ 'average',
606
+ 'min',
607
+ 'max',
608
+ 'show_original',
609
+ 'percent_checked',
610
+ ]),
611
+ })
612
+ .optional()
613
+ .describe('rollup only: what to gather through which relation'),
614
+ prefix: z.string().optional().describe('unique_id only: prefix for the number, e.g. "TASK" → "TASK-42"'),
345
615
  },
346
616
  },
347
- async ({ databaseId, name, type, options }) => {
617
+ async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix }) => {
348
618
  try {
349
619
  const client = makeClient();
350
620
  const id = await resolveNodeId(client, databaseId);
351
- const property = await client.addColumn(id, { name, type, options });
621
+ const property = await client.addColumn(id, {
622
+ name,
623
+ type,
624
+ options,
625
+ relationDatabaseId,
626
+ formula,
627
+ rollup,
628
+ prefix,
629
+ });
352
630
  return jsonResult({ property });
353
631
  } catch (err) {
354
632
  return errorResult(err);
@@ -387,6 +665,131 @@ export function registerDatabaseTools(server) {
387
665
  },
388
666
  );
389
667
 
668
+ // ── DB-PERM-001 / DB-WATCH — 노션에 없는 두 가지: 자물쇠와 알림 ────────────
669
+ //
670
+ // 표를 만들 수 있으면 잠글 수도, 누구에게 알릴지 정할 수도 있어야 한다. 이게 없으면
671
+ // 에이전트가 "지원서 표 만들어 줘. 서로 못 보게 하고, 새 지원 들어오면 김승수한테
672
+ // 알려 줘" 같은 부탁의 절반만 해내고 만다.
673
+
674
+ server.registerTool(
675
+ 'rootr_read_database_permissions',
676
+ {
677
+ title: 'Read a database\'s locks',
678
+ description:
679
+ 'Read the three locks on a Rootr (루터) DATABASE — schema lock (only managers change columns/views), ' +
680
+ 'own-rows-only (people see just what they submitted), and manager-only columns. Notion has none of these.',
681
+ annotations: { readOnlyHint: true, destructiveHint: false },
682
+ inputSchema: { databaseId: z.string().describe(DATABASE_TARGET_DESC) },
683
+ },
684
+ async ({ databaseId }) => {
685
+ try {
686
+ const client = makeClient();
687
+ const id = await resolveNodeId(client, databaseId);
688
+ return jsonResult(await client.getDatabasePermissions(id));
689
+ } catch (err) {
690
+ return errorResult(err);
691
+ }
692
+ },
693
+ );
694
+
695
+ server.registerTool(
696
+ 'rootr_set_database_permissions',
697
+ {
698
+ title: 'Lock a database',
699
+ description:
700
+ 'Set the locks on a Rootr (루터) DATABASE. `schemaLock: "managers"` stops editors from changing columns and ' +
701
+ 'views; `rowScope: "own"` hides other people\'s rows (application forms, report boxes); `lockedProps` names ' +
702
+ 'the columns only managers may edit (approval flags, final scores). Omitted fields keep their current value. ' +
703
+ 'Requires MANAGE on the table.',
704
+ annotations: { readOnlyHint: false, destructiveHint: false },
705
+ inputSchema: {
706
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
707
+ schemaLock: z.enum(['open', 'managers']).optional().describe('Who may change columns and views'),
708
+ rowScope: z.enum(['all', 'own']).optional().describe('"own" = each person sees only the rows they created'),
709
+ lockedProps: z
710
+ .array(z.string())
711
+ .optional()
712
+ .describe('Column ids (or names) only managers may edit'),
713
+ },
714
+ },
715
+ async ({ databaseId, schemaLock, rowScope, lockedProps }) => {
716
+ try {
717
+ const client = makeClient();
718
+ const id = await resolveNodeId(client, databaseId);
719
+ const body = {};
720
+ if (schemaLock !== undefined) body.schemaLock = schemaLock;
721
+ if (rowScope !== undefined) body.rowScope = rowScope;
722
+ if (lockedProps !== undefined) body.lockedProps = lockedProps;
723
+ return jsonResult(await client.setDatabasePermissions(id, body));
724
+ } catch (err) {
725
+ return errorResult(err);
726
+ }
727
+ },
728
+ );
729
+
730
+ server.registerTool(
731
+ 'rootr_read_database_notifications',
732
+ {
733
+ title: 'Read a database\'s notification rules',
734
+ description:
735
+ 'Read who gets notified when rows are added, changed or deleted in a Rootr (루터) DATABASE. ' +
736
+ 'Notion cannot do this — there each person must turn on watching for themselves.',
737
+ annotations: { readOnlyHint: true, destructiveHint: false },
738
+ inputSchema: { databaseId: z.string().describe(DATABASE_TARGET_DESC) },
739
+ },
740
+ async ({ databaseId }) => {
741
+ try {
742
+ const client = makeClient();
743
+ const id = await resolveNodeId(client, databaseId);
744
+ return jsonResult(await client.getDatabaseNotifications(id));
745
+ } catch (err) {
746
+ return errorResult(err);
747
+ }
748
+ },
749
+ );
750
+
751
+ server.registerTool(
752
+ 'rootr_set_database_notifications',
753
+ {
754
+ title: 'Set who gets notified about a database',
755
+ description:
756
+ 'Replace the notification rules on a Rootr (루터) DATABASE. Each rule names the people to notify (`userIds`), ' +
757
+ 'when (`events`: created / updated / deleted), and optionally a filter so only some rows ping — ' +
758
+ '`{propId, op: "is"|"contains"|"changed", value}` (e.g. status is 긴급, owner changed). ' +
759
+ 'Rules replace the existing set; pass [] to clear. Requires MANAGE. A person is never notified about their own change.',
760
+ annotations: { readOnlyHint: false, destructiveHint: true },
761
+ inputSchema: {
762
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
763
+ watchers: z
764
+ .array(
765
+ z.object({
766
+ id: z.string().optional(),
767
+ userIds: z.array(z.string()).describe('Workspace member user ids to notify'),
768
+ events: z.array(z.enum(['created', 'updated', 'deleted'])),
769
+ filter: z
770
+ .object({
771
+ propId: z.string(),
772
+ op: z.enum(['is', 'contains', 'changed']),
773
+ value: z.string().optional(),
774
+ })
775
+ .optional(),
776
+ label: z.string().optional(),
777
+ }),
778
+ )
779
+ .describe('The complete rule set (replaces what is stored)'),
780
+ },
781
+ },
782
+ async ({ databaseId, watchers }) => {
783
+ try {
784
+ const client = makeClient();
785
+ const id = await resolveNodeId(client, databaseId);
786
+ return jsonResult(await client.setDatabaseNotifications(id, watchers));
787
+ } catch (err) {
788
+ return errorResult(err);
789
+ }
790
+ },
791
+ );
792
+
390
793
  server.registerTool(
391
794
  'rootr_delete_column',
392
795
  {