rootr-cli 0.25.0 → 0.29.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/README.ko.md +53 -3
- package/README.md +62 -4
- package/bin/rootr.js +667 -13
- package/lib/client.js +131 -7
- package/lib/domain-billing.js +52 -0
- package/lib/mcp-tools/booking.js +11 -1
- package/lib/mcp-tools/databases.js +423 -10
- package/lib/mcp-tools/documents.js +17 -1
- package/lib/mcp-tools/domains.js +236 -0
- package/lib/mcp-tools/forms.js +20 -2
- package/lib/mcp-tools/pages.js +21 -4
- package/lib/mcp-tools/public.js +13 -0
- package/lib/mcp-tools/workspaces.js +71 -1
- package/lib/mcp.js +2 -0
- package/package.json +1 -1
|
@@ -59,6 +59,8 @@ const FILTER_SHAPE = z.array(
|
|
|
59
59
|
.optional()
|
|
60
60
|
.describe(
|
|
61
61
|
'Values the op needs. is/is_not/contains → one or more (OR between them). ' +
|
|
62
|
+
'Text matching ignores case, spaces and brackets/punctuation ( ) [ ] · - . , / and treats ㈜ as (주). On a RELATION column, contains matches the linked rows\' ' +
|
|
63
|
+
'titles (e.g. a business name) and is/is_not take linked row ids (from rootr_pick_rows) or exact titles. ' +
|
|
62
64
|
'before/after/on → one date "YYYY-MM-DD". between → two dates. eq/gt/lt → one number. ' +
|
|
63
65
|
'is_empty/is_not_empty/today/this_week/this_month/past/upcoming/checked/unchecked take none.',
|
|
64
66
|
),
|
|
@@ -324,9 +326,25 @@ export function registerDatabaseTools(server) {
|
|
|
324
326
|
'Default month. The year view shows twelve mini-months at once (darker = more rows that day). ' +
|
|
325
327
|
'Saved on the view, so everyone who opens it sees that span.',
|
|
326
328
|
),
|
|
329
|
+
density: z
|
|
330
|
+
.enum(['normal', 'compact'])
|
|
331
|
+
.nullable()
|
|
332
|
+
.optional()
|
|
333
|
+
.describe('table view: "compact" = lower rows and smaller text so more rows fit on one screen. null/"normal" = default.'),
|
|
334
|
+
frozenColumns: z
|
|
335
|
+
.number()
|
|
336
|
+
.int()
|
|
337
|
+
.min(0)
|
|
338
|
+
.max(3)
|
|
339
|
+
.nullable()
|
|
340
|
+
.optional()
|
|
341
|
+
.describe(
|
|
342
|
+
'table view: keep the first N VISIBLE columns fixed on the left while scrolling sideways (Excel freeze panes). ' +
|
|
343
|
+
'0/null = off. e.g. 1 keeps the title column (상호명) in place.',
|
|
344
|
+
),
|
|
327
345
|
},
|
|
328
346
|
},
|
|
329
|
-
async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale }) => {
|
|
347
|
+
async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns }) => {
|
|
330
348
|
try {
|
|
331
349
|
const client = makeClient();
|
|
332
350
|
const id = await resolveNodeId(client, databaseId);
|
|
@@ -340,6 +358,8 @@ export function registerDatabaseTools(server) {
|
|
|
340
358
|
sort,
|
|
341
359
|
colWidths,
|
|
342
360
|
calendarScale,
|
|
361
|
+
density,
|
|
362
|
+
frozenColumns,
|
|
343
363
|
});
|
|
344
364
|
return jsonResult(view);
|
|
345
365
|
} catch (err) {
|
|
@@ -382,7 +402,9 @@ export function registerDatabaseTools(server) {
|
|
|
382
402
|
'the same table can look different per view (a "담당자" view hiding the amounts, etc). Hiding a column ' +
|
|
383
403
|
'does NOT erase its values: it disappears from that view only and comes back when you unhide it. ' +
|
|
384
404
|
'Columns may be given by NAME or id; the title column cannot be hidden and is ignored if listed. ' +
|
|
385
|
-
'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched.'
|
|
405
|
+
'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched. ' +
|
|
406
|
+
'For an at-a-glance table also pass density "compact" (lower rows, smaller text) and frozenColumns N (first N visible ' +
|
|
407
|
+
'columns stay fixed while scrolling sideways).',
|
|
386
408
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
387
409
|
inputSchema: {
|
|
388
410
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
@@ -436,6 +458,22 @@ export function registerDatabaseTools(server) {
|
|
|
436
458
|
'Tint rows/cards by value: the first matching rule wins. e.g. [{propId:"진행상태",op:"is",values:["채용불가"],color:"red"}, ' +
|
|
437
459
|
'{propId:"면접예정일",op:"is_not_empty",color:"yellow"}]. Colors: gray, brown, orange, yellow, green, blue, purple, pink, red. null or [] turns it off.',
|
|
438
460
|
),
|
|
461
|
+
density: z
|
|
462
|
+
.enum(['normal', 'compact'])
|
|
463
|
+
.nullable()
|
|
464
|
+
.optional()
|
|
465
|
+
.describe('table view: "compact" = lower rows and smaller text so more rows fit on one screen. null/"normal" = default.'),
|
|
466
|
+
frozenColumns: z
|
|
467
|
+
.number()
|
|
468
|
+
.int()
|
|
469
|
+
.min(0)
|
|
470
|
+
.max(3)
|
|
471
|
+
.nullable()
|
|
472
|
+
.optional()
|
|
473
|
+
.describe(
|
|
474
|
+
'table view: keep the first N VISIBLE columns fixed on the left while scrolling sideways (Excel freeze panes). ' +
|
|
475
|
+
'0/null = off. e.g. 1 keeps the title column (상호명) in place.',
|
|
476
|
+
),
|
|
439
477
|
},
|
|
440
478
|
},
|
|
441
479
|
async ({
|
|
@@ -452,6 +490,8 @@ export function registerDatabaseTools(server) {
|
|
|
452
490
|
calendarScale,
|
|
453
491
|
cardProps,
|
|
454
492
|
rowColors,
|
|
493
|
+
density,
|
|
494
|
+
frozenColumns,
|
|
455
495
|
}) => {
|
|
456
496
|
try {
|
|
457
497
|
const client = makeClient();
|
|
@@ -468,6 +508,8 @@ export function registerDatabaseTools(server) {
|
|
|
468
508
|
calendarScale,
|
|
469
509
|
cardProps,
|
|
470
510
|
rowColors,
|
|
511
|
+
density,
|
|
512
|
+
frozenColumns,
|
|
471
513
|
});
|
|
472
514
|
return jsonResult({ view });
|
|
473
515
|
} catch (err) {
|
|
@@ -482,7 +524,8 @@ export function registerDatabaseTools(server) {
|
|
|
482
524
|
title: 'Read a database schema and rows',
|
|
483
525
|
description:
|
|
484
526
|
'Read a Rootr (루터) DATABASE node — its schema (properties with ids/types and select options) plus all rows ' +
|
|
485
|
-
'(each with its rowId and values
|
|
527
|
+
'(each with its rowId and values; a row whose current value breaks a column check also carries ' +
|
|
528
|
+
'`issues: { [propId]: { message, askDeleteRow? } }` — saved as typed, just flagged). ALWAYS call this before rootr_add_row / rootr_update_row so you know the exact ' +
|
|
486
529
|
'property ids/names and, for board/kanban views, which select column holds the status. Only works on DATABASE nodes.',
|
|
487
530
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
488
531
|
inputSchema: {
|
|
@@ -520,6 +563,7 @@ export function registerDatabaseTools(server) {
|
|
|
520
563
|
title: 'List database rows',
|
|
521
564
|
description:
|
|
522
565
|
'List the rows of a Rootr (루터) DATABASE (id + values + position), without the schema. ' +
|
|
566
|
+
'Rows whose current values break a column check carry `issues: { [propId]: { message, askDeleteRow? } }` (omitted when none). ' +
|
|
523
567
|
'Use rootr_read_database instead when you also need the property/column definitions.',
|
|
524
568
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
525
569
|
inputSchema: {
|
|
@@ -551,6 +595,40 @@ export function registerDatabaseTools(server) {
|
|
|
551
595
|
},
|
|
552
596
|
);
|
|
553
597
|
|
|
598
|
+
// REL-PICK(P5) — 화면의 관계 고르기와 같은 찾기. 표 전체를 읽어 이름을 손으로 견주지 않게(9천 행이면 수 MB).
|
|
599
|
+
server.registerTool(
|
|
600
|
+
'rootr_pick_rows',
|
|
601
|
+
{
|
|
602
|
+
title: 'Find rows to link (relation picker search)',
|
|
603
|
+
description:
|
|
604
|
+
'Search a Rootr (루터) DATABASE for rows to put into a relation column — the same search the app\'s relation picker uses. ' +
|
|
605
|
+
'Searches the WHOLE table by title: exact name → names starting with q → names containing q → the identify columns ' +
|
|
606
|
+
'(e.g. last 4 phone digits "5678"); newest rows first within each rank. Returns `{ rows: [{ id, title, subtitle }], total, hasMore }` ' +
|
|
607
|
+
'only — `subtitle` comes from the title column\'s identifyProps (rootr_update_column) such as "***-5678 · 2026-10-02", so ' +
|
|
608
|
+
'people with the same name can be told apart. Pass the relation column\'s relationFilter as `filters` to see only linkable rows, ' +
|
|
609
|
+
'or `ids` to label rows you already have. Matching ignores spaces/case/brackets/punctuation and treats ㈜ as (주).',
|
|
610
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
611
|
+
inputSchema: {
|
|
612
|
+
databaseId: z.string().describe('The TARGET database (the one the relation column points at): ' + DATABASE_TARGET_DESC),
|
|
613
|
+
q: z.string().optional().describe('Name (or part of it, or last phone digits). Empty → newest rows.'),
|
|
614
|
+
filters: z
|
|
615
|
+
.array(z.object({ propId: z.string(), op: z.string(), values: z.array(z.string()).optional() }))
|
|
616
|
+
.optional()
|
|
617
|
+
.describe('Optional conditions on the target table, e.g. the relation column\'s relationFilter'),
|
|
618
|
+
ids: z.array(z.string()).optional().describe('Only these row ids (to label them); q/filters are ignored'),
|
|
619
|
+
limit: z.number().optional().describe('Default 30, max 100'),
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
async ({ databaseId, q, filters, ids, limit }) => {
|
|
623
|
+
try {
|
|
624
|
+
const client = makeClient();
|
|
625
|
+
return jsonResult(await client.pickRows(await resolveNodeId(client, databaseId), { q, filters, ids, limit }));
|
|
626
|
+
} catch (err) {
|
|
627
|
+
return errorResult(err);
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
);
|
|
631
|
+
|
|
554
632
|
server.registerTool(
|
|
555
633
|
'rootr_add_row',
|
|
556
634
|
{
|
|
@@ -908,6 +986,8 @@ export function registerDatabaseTools(server) {
|
|
|
908
986
|
'rootr_query_vectors (Rootr never embeds it for you; people see only "vector · N dims" in the grid). ' +
|
|
909
987
|
'created_time / last_edited_time / created_by / last_edited_by / unique_id / formula / rollup are filled in by the server — ' +
|
|
910
988
|
'they take no value and rootr_update_row will refuse to set them. ' +
|
|
989
|
+
'Display options: align (left|center|right), wrap true; date columns: highlightOverdue true (past dates red — off by default), ' +
|
|
990
|
+
'allowTime false (no time), allowRange false (no end date). ' +
|
|
911
991
|
'Returns the created column WITH ITS id.',
|
|
912
992
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
913
993
|
inputSchema: {
|
|
@@ -1014,9 +1094,38 @@ export function registerDatabaseTools(server) {
|
|
|
1014
1094
|
'chat, button AI-fill steps, formula/rollup columns and MCP reads still see the values; to hide values from ' +
|
|
1015
1095
|
'people, use an "own rows only" table or a private node. Table managers only.',
|
|
1016
1096
|
),
|
|
1097
|
+
align: z
|
|
1098
|
+
.enum(['left', 'center', 'right'])
|
|
1099
|
+
.optional()
|
|
1100
|
+
.describe('Text alignment of this column in the table (default: numbers right, the rest left). null resets.'),
|
|
1101
|
+
wrap: z
|
|
1102
|
+
.boolean()
|
|
1103
|
+
.optional()
|
|
1104
|
+
.describe('true = long text wraps inside the cell instead of being cut off (the row grows). false/null = one line.'),
|
|
1105
|
+
highlightOverdue: z
|
|
1106
|
+
.boolean()
|
|
1107
|
+
.optional()
|
|
1108
|
+
.describe(
|
|
1109
|
+
'date only: show PAST dates in red (deadline-like columns). Default OFF — application/call dates stay plain. ' +
|
|
1110
|
+
'Refused (400) on a non-date column.',
|
|
1111
|
+
),
|
|
1112
|
+
allowTime: z
|
|
1113
|
+
.boolean()
|
|
1114
|
+
.optional()
|
|
1115
|
+
.describe(
|
|
1116
|
+
'date only: false = no 「시간」 toggle; any value written later is stored as a plain date (time dropped). ' +
|
|
1117
|
+
'Existing values keep showing. true/null = allowed (default).',
|
|
1118
|
+
),
|
|
1119
|
+
allowRange: z
|
|
1120
|
+
.boolean()
|
|
1121
|
+
.optional()
|
|
1122
|
+
.describe(
|
|
1123
|
+
'date only: false = no 「기간」 (end date) button; values written later keep only the start date. ' +
|
|
1124
|
+
'Existing ranges keep showing and can be cleared. true/null = allowed (default).',
|
|
1125
|
+
),
|
|
1017
1126
|
},
|
|
1018
1127
|
},
|
|
1019
|
-
async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix, searchExcluded, dimensions }) => {
|
|
1128
|
+
async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix, searchExcluded, dimensions, align, wrap, highlightOverdue, allowTime, allowRange }) => {
|
|
1020
1129
|
try {
|
|
1021
1130
|
const client = makeClient();
|
|
1022
1131
|
const id = await resolveNodeId(client, databaseId);
|
|
@@ -1030,6 +1139,11 @@ export function registerDatabaseTools(server) {
|
|
|
1030
1139
|
prefix,
|
|
1031
1140
|
searchExcluded,
|
|
1032
1141
|
dimensions,
|
|
1142
|
+
align,
|
|
1143
|
+
wrap,
|
|
1144
|
+
highlightOverdue,
|
|
1145
|
+
allowTime,
|
|
1146
|
+
allowRange,
|
|
1033
1147
|
});
|
|
1034
1148
|
return jsonResult({ property });
|
|
1035
1149
|
} catch (err) {
|
|
@@ -1049,15 +1163,29 @@ export function registerDatabaseTools(server) {
|
|
|
1049
1163
|
'The column TYPE and a vector column\'s dimensions cannot be changed — that would rewrite every row (or every stored vector) — ' +
|
|
1050
1164
|
'so passing type or dimensions is refused; add a new column and move the values instead. ' +
|
|
1051
1165
|
'Call rootr_read_database first to get the exact propertyId.\n' +
|
|
1166
|
+
'RENAMES CASCADE: a new `name` also rewrites everything that refers to the column BY NAME, in the same transaction — ' +
|
|
1167
|
+
'formulas ({col} / prop("col")) in this table, button/automation {col} {col+1} {relation.col} templates here and in ' +
|
|
1168
|
+
'tables that link to this one, rollups in other tables (pinned to the column id), linked form questions (kept connected ' +
|
|
1169
|
+
'by id; the question text stays) and page live blocks (liveTable/liveStats/liveChart). Do NOT edit the formula first ' +
|
|
1170
|
+
'(a formula naming a column that does not exist yet is refused). The result has `renameCascade` {formulas, buttons, ' +
|
|
1171
|
+
'automations, rollups, forms, pages, warnings, summary}; `warnings` lists what could not follow (e.g. two form questions ' +
|
|
1172
|
+
'now landing on one column). Pass dryRun: true with `name` to get that list without changing anything.\n' +
|
|
1052
1173
|
'`reverseName` on a relation column pairs it with the linked table: that table gets a matching column with ' +
|
|
1053
1174
|
'this name and filling either side fills the other. Pass an empty string to unpair (neither column is deleted). ' +
|
|
1054
1175
|
'Use it to make an existing one-way link two-way rather than rebuilding the table — a one-way column leaves ' +
|
|
1055
|
-
'every aggregate on the other side wrong until somebody backfills it by hand
|
|
1176
|
+
'every aggregate on the other side wrong until somebody backfills it by hand.\n' +
|
|
1177
|
+
'Display options (only what you pass changes; null resets): align left|center|right, wrap true (long text wraps), ' +
|
|
1178
|
+
'and for DATE columns highlightOverdue true (past dates in red — off by default), allowTime false (no time), ' +
|
|
1179
|
+
'allowRange false (no end date). e.g. { "allowTime": false, "allowRange": false } makes 「지원일」 a plain single date.',
|
|
1056
1180
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1057
1181
|
inputSchema: {
|
|
1058
1182
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1059
1183
|
propertyId: z.string().describe('Column id from rootr_read_database (NOT the header text)'),
|
|
1060
|
-
name: z.string().optional().describe('New header text'),
|
|
1184
|
+
name: z.string().optional().describe('New header text — formulas, button/automation templates, rollups, linked forms and page blocks follow it'),
|
|
1185
|
+
dryRun: z
|
|
1186
|
+
.boolean()
|
|
1187
|
+
.optional()
|
|
1188
|
+
.describe('With `name` only: return what the rename would also change (renameCascade) without renaming'),
|
|
1061
1189
|
options: z
|
|
1062
1190
|
.array(z.object({ name: z.string(), color: z.string() }))
|
|
1063
1191
|
.optional()
|
|
@@ -1107,6 +1235,40 @@ export function registerDatabaseTools(server) {
|
|
|
1107
1235
|
'shows the latest 5 linked rows (contact history) and the total. Names or ids; an unknown name is refused (400), not dropped. ' +
|
|
1108
1236
|
'null/[] = automatic (status, notes, dates, rollups; relations as history).',
|
|
1109
1237
|
),
|
|
1238
|
+
align: z
|
|
1239
|
+
.enum(['left', 'center', 'right'])
|
|
1240
|
+
.nullable()
|
|
1241
|
+
.optional()
|
|
1242
|
+
.describe('Text alignment of this column in the table (default: numbers right, the rest left). null resets.'),
|
|
1243
|
+
wrap: z
|
|
1244
|
+
.boolean()
|
|
1245
|
+
.nullable()
|
|
1246
|
+
.optional()
|
|
1247
|
+
.describe('true = long text wraps inside the cell instead of being cut off (the row grows). false/null = one line.'),
|
|
1248
|
+
highlightOverdue: z
|
|
1249
|
+
.boolean()
|
|
1250
|
+
.nullable()
|
|
1251
|
+
.optional()
|
|
1252
|
+
.describe(
|
|
1253
|
+
'date only: show PAST dates in red (deadline-like columns). Default OFF — application/call dates stay plain. ' +
|
|
1254
|
+
'Refused (400) on a non-date column.',
|
|
1255
|
+
),
|
|
1256
|
+
allowTime: z
|
|
1257
|
+
.boolean()
|
|
1258
|
+
.nullable()
|
|
1259
|
+
.optional()
|
|
1260
|
+
.describe(
|
|
1261
|
+
'date only: false = no 「시간」 toggle; any value written later is stored as a plain date (time dropped). ' +
|
|
1262
|
+
'Existing values keep showing. true/null = allowed (default).',
|
|
1263
|
+
),
|
|
1264
|
+
allowRange: z
|
|
1265
|
+
.boolean()
|
|
1266
|
+
.nullable()
|
|
1267
|
+
.optional()
|
|
1268
|
+
.describe(
|
|
1269
|
+
'date only: false = no 「기간」 (end date) button; values written later keep only the start date. ' +
|
|
1270
|
+
'Existing ranges keep showing and can be cleared. true/null = allowed (default).',
|
|
1271
|
+
),
|
|
1110
1272
|
relationFilter: z
|
|
1111
1273
|
.array(z.object({ propId: z.string(), op: z.enum(['is', 'is_not_empty']), values: z.array(z.string()).optional() }))
|
|
1112
1274
|
.nullable()
|
|
@@ -1115,6 +1277,66 @@ export function registerDatabaseTools(server) {
|
|
|
1115
1277
|
'relation only: which rows of the linked table may be linked (all rules must match; propId is a column of the LINKED table). ' +
|
|
1116
1278
|
'e.g. [{propId:"진행상태",op:"is",values:["채용"]}]. The picker hides others and the server refuses new links that fail. null turns it off.',
|
|
1117
1279
|
),
|
|
1280
|
+
dedupeCarry: z
|
|
1281
|
+
.record(z.any())
|
|
1282
|
+
.nullable()
|
|
1283
|
+
.optional()
|
|
1284
|
+
.describe(
|
|
1285
|
+
'With dedupe on: "carry over previous info" (the same entry came back). When a NEW row has the same value as an earlier row, copy the ' +
|
|
1286
|
+
'earlier row\'s values INTO the new row (the earlier row is untouched). { auto?: true = do it on every entry path (row add, cell, ' +
|
|
1287
|
+
'form, button/automation add_row, REST/MCP/bulk) without asking; false/omitted = only the duplicate dialog button / ' +
|
|
1288
|
+
'rootr_carry_over. label?: name of this action for the table (≤30 chars, e.g. "재등록"; toasts/merge comments use it; blank = neutral ' +
|
|
1289
|
+
'default), exclude?: [columns not copied, e.g. "접수일"], overwrite?: [columns copied even if the new row has a value; ' +
|
|
1290
|
+
'default fills empty cells only], relations?: true to copy relation columns too (default false — they are two-way), ' +
|
|
1291
|
+
'latestBy?: date column or "createdAt" — which earlier row wins when several match (latest), mark?: values stamped on the new ' +
|
|
1292
|
+
'row: { "재등록 여부": true, "구분": { map: { "A": "A(재등록)" }, else: "재등록" } } — map looks at the value the ' +
|
|
1293
|
+
'NEW row came in with; else covers any value not in map; if that column is still EMPTY when carried, the mark waits and is ' +
|
|
1294
|
+
'applied once to the first value written there later (e.g. a later button step); missing select options are added }. ' +
|
|
1295
|
+
'Names or ids. null turns it off. ' +
|
|
1296
|
+
'DUPLICATE RULES (all optional, same object): action?: "ask" (dialog; askDefault?: carry|merge_into_old|merge_all preselected) | ' +
|
|
1297
|
+
'"carry" (= auto:true; copy into the new row) | "merge_into_old" (fill the representative earlier row, delete the new row unless ' +
|
|
1298
|
+
'keepNew:true which keeps it linked) | "merge_all" (all same-value rows → one representative row; others deleted or kept linked); ' +
|
|
1299
|
+
'sources?: "all" (default — per column take the most recent earlier row that HAS a value) | "latest" (only the latest earlier row); ' +
|
|
1300
|
+
'strategy?: default per column, one of fill_empty (default) | prefer_new | prefer_old | latest | skip; columns?: { "<column>": ' +
|
|
1301
|
+
'fill_empty | prefer_new | prefer_old | latest | skip | max | min | sum (number) | earliest | latest_date (date) | union ' +
|
|
1302
|
+
'(multi_select/relation/files/person) | { append: { sep?: " / ", datePrefix?: true } } (text) } — a strategy on the wrong column ' +
|
|
1303
|
+
'type is a 400; exclude = skip, overwrite = prefer_old; representative?: "latest" | "oldest", representativeWhen?: [{ propId, op, ' +
|
|
1304
|
+
'values }] (first row matching wins). Every operation returns an opId (undo with rootr_dedupe_undo); preview with ' +
|
|
1305
|
+
'rootr_dedupe_preview. ' +
|
|
1306
|
+
'BY CONDITION: cases?: [{ when?: { within?: { column: "<date column>", days: 0 }, same?: ["<column>"], filters?: [{ propId, op, ' +
|
|
1307
|
+
'values }] (on the earlier row) }, action, …overrides (askDefault/sources/strategy/columns/keepNew/representative/' +
|
|
1308
|
+
'representativeWhen/mark/label; "mark": {} = no marks) }] (≤10) — each earlier row goes to the FIRST matching case, rows ' +
|
|
1309
|
+
'matching none are untouched; any row in an ask case → nothing automatic; merge cases run first, then carry cases fill the ' +
|
|
1310
|
+
'surviving row, which gets the first carry case\'s mark/label (else the merge case\'s). e.g. { cases: [{ when: { within: ' +
|
|
1311
|
+
'{ column: "등록일", days: 0 } }, action: "merge_all", mark: {} }, { action: "carry" }] }. TEMPLATES: map/else values may ' +
|
|
1312
|
+
'use {value} = the value the new row came in with: { "경로": { else: "{value} (다시)" } }; a value already in that shape is ' +
|
|
1313
|
+
'kept; new values become select options.',
|
|
1314
|
+
),
|
|
1315
|
+
validate: z
|
|
1316
|
+
.record(z.any())
|
|
1317
|
+
.nullable()
|
|
1318
|
+
.optional()
|
|
1319
|
+
.describe(
|
|
1320
|
+
'Value check for NEW writes to this column — it only FLAGS a wrong input: the value is saved exactly as typed and the ' +
|
|
1321
|
+
'response carries warnings[] (send checkWarnings: true on row PATCH/form submit to get 422 VALUE_WARNINGS first). The user ' +
|
|
1322
|
+
'writes the rule: { kind: "pattern", pattern: "<regex the WHOLE value must match>", flags?: "i"|"s"|"m", negate?: true ' +
|
|
1323
|
+
'(flag values that CONTAIN the pattern instead), message?: "{name}: {value} …" } — linear-time engine, no back-references/' +
|
|
1324
|
+
'look-arounds, max 500 chars; starting points via rootr_test_value_rule { presets: true } (email, URL, intl/KR/US phone, ' +
|
|
1325
|
+
'postal codes …); formats differ per country, so pick/adapt one rather than assuming Korean formats. Other kinds: ' +
|
|
1326
|
+
'"range" (number: min/max), "date" (text reads as a date), "birthdate" (text/date; min/max = age). askDeleteRow?: true — ' +
|
|
1327
|
+
'the in-app dialog also offers deleting the row. Applies to cells, row panel, forms, REST/MCP/bulk, buttons/automations. ' +
|
|
1328
|
+
'Existing values are not changed. null turns it off.',
|
|
1329
|
+
),
|
|
1330
|
+
identifyProps: z
|
|
1331
|
+
.array(z.object({ propId: z.string(), mask: z.enum(['last4']).optional() }))
|
|
1332
|
+
.nullable()
|
|
1333
|
+
.optional()
|
|
1334
|
+
.describe(
|
|
1335
|
+
'TITLE column only: up to 3 columns of THIS table shown next to the title wherever another table picks one of these rows ' +
|
|
1336
|
+
'(relation picker, relation filter, rootr_pick_rows `subtitle`) so same-name rows can be told apart. mask "last4" shows only ' +
|
|
1337
|
+
'the last 4 digits (***-5678) — use it for phone numbers. Names or ids; unknown/relation/files columns are refused (400). ' +
|
|
1338
|
+
'e.g. on 지원자.이름: [{propId:"연락처",mask:"last4"},{propId:"지원일"}]. null/[] turns it off.',
|
|
1339
|
+
),
|
|
1118
1340
|
},
|
|
1119
1341
|
},
|
|
1120
1342
|
async ({
|
|
@@ -1131,8 +1353,17 @@ export function registerDatabaseTools(server) {
|
|
|
1131
1353
|
dedupe,
|
|
1132
1354
|
dedupeShow,
|
|
1133
1355
|
relationFilter,
|
|
1356
|
+
align,
|
|
1357
|
+
wrap,
|
|
1358
|
+
highlightOverdue,
|
|
1359
|
+
allowTime,
|
|
1360
|
+
allowRange,
|
|
1361
|
+
dedupeCarry,
|
|
1362
|
+
validate,
|
|
1363
|
+
identifyProps,
|
|
1134
1364
|
type,
|
|
1135
1365
|
dimensions,
|
|
1366
|
+
dryRun,
|
|
1136
1367
|
}) => {
|
|
1137
1368
|
// 조용히 무시하지 않는다 — 못 바꾸는 것을 넘기면 까닭과 할 일을 말한다(벡터 차원을 바꾸면 저장된 벡터가 전부 틀려진다).
|
|
1138
1369
|
if (type !== undefined || dimensions !== undefined) {
|
|
@@ -1146,6 +1377,11 @@ export function registerDatabaseTools(server) {
|
|
|
1146
1377
|
try {
|
|
1147
1378
|
const client = makeClient();
|
|
1148
1379
|
const id = await resolveNodeId(client, databaseId);
|
|
1380
|
+
// COL-RENAME — 미리 보기: 이름을 바꾸면 무엇이 함께 바뀌는지(쓰지 않는다).
|
|
1381
|
+
if (dryRun) {
|
|
1382
|
+
if (typeof name !== 'string' || !name.trim()) return errorResult(new Error('dryRun 은 name(바꿀 이름)과 함께 보내 주세요.'));
|
|
1383
|
+
return jsonResult({ dryRun: true, renameCascade: await client.columnRenameImpact(id, propertyId, name) });
|
|
1384
|
+
}
|
|
1149
1385
|
const property = await client.updateColumn(id, propertyId, {
|
|
1150
1386
|
name,
|
|
1151
1387
|
options,
|
|
@@ -1158,8 +1394,18 @@ export function registerDatabaseTools(server) {
|
|
|
1158
1394
|
dedupe,
|
|
1159
1395
|
dedupeShow,
|
|
1160
1396
|
relationFilter,
|
|
1397
|
+
align,
|
|
1398
|
+
wrap,
|
|
1399
|
+
highlightOverdue,
|
|
1400
|
+
allowTime,
|
|
1401
|
+
allowRange,
|
|
1402
|
+
dedupeCarry,
|
|
1403
|
+
validate,
|
|
1404
|
+
identifyProps,
|
|
1161
1405
|
});
|
|
1162
|
-
|
|
1406
|
+
// COL-RENAME — 함께 바뀐 것을 위로 꺼내 둔다(에이전트가 경고를 놓치지 않게).
|
|
1407
|
+
const { renameCascade, ...column } = property ?? {};
|
|
1408
|
+
return jsonResult(renameCascade ? { property: column, renameCascade } : { property });
|
|
1163
1409
|
} catch (err) {
|
|
1164
1410
|
return errorResult(err);
|
|
1165
1411
|
}
|
|
@@ -1168,7 +1414,7 @@ export function registerDatabaseTools(server) {
|
|
|
1168
1414
|
|
|
1169
1415
|
// ── DEDUPE(F5) — 같은 사람 찾기·합치기·새 행으로 두기 ─────────────────────
|
|
1170
1416
|
//
|
|
1171
|
-
// 화면의 중복 창과 같은 일을 에이전트도 할 수 있어야 한다. 그렇지 않으면 "
|
|
1417
|
+
// 화면의 중복 창과 같은 일을 에이전트도 할 수 있어야 한다. 그렇지 않으면 "명단 붙여 넣으면서 이미 있는 사람은 합쳐 줘"
|
|
1172
1418
|
// 같은 부탁에서 에이전트가 표 전체를 읽어 전화번호를 손으로 견주게 된다(9천 행이면 수 MB).
|
|
1173
1419
|
|
|
1174
1420
|
server.registerTool(
|
|
@@ -1181,7 +1427,7 @@ export function registerDatabaseTools(server) {
|
|
|
1181
1427
|
'and spaces. Give `rowId` to check an existing row (it is excluded from the result) or `values` ({column name or id: value}) ' +
|
|
1182
1428
|
'to check before adding. Each match comes with the fields the duplicate dialog shows (`preview`: status, notes, dates, rollups ' +
|
|
1183
1429
|
'— or the column\'s dedupeShow list) and `history`: the latest 5 linked rows per relation column (e.g. call log) with the total. ' +
|
|
1184
|
-
'Only rows the caller can see are returned. Then use rootr_merge_rows or
|
|
1430
|
+
'Only rows the caller can see are returned. Then use rootr_merge_rows, rootr_keep_duplicate or rootr_carry_over (copy the earlier row into the new one).',
|
|
1185
1431
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
1186
1432
|
inputSchema: {
|
|
1187
1433
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
@@ -1208,7 +1454,7 @@ export function registerDatabaseTools(server) {
|
|
|
1208
1454
|
description:
|
|
1209
1455
|
'Merge row `rowId` into row `into` of the same Rootr (루터) DATABASE — the "same person applied again" case. Empty cells of ' +
|
|
1210
1456
|
'`into` are filled from `rowId` (existing values are kept), relation/multi-select/people/files are unioned (so a call log linked ' +
|
|
1211
|
-
'to the new row moves to the old one), a "🔁
|
|
1457
|
+
'to the new row moves to the old one), a "🔁 <dedupeCarry.label or 다시 들어옴>" comment listing the new values is left on `into`, and `rowId` is DELETED ' +
|
|
1212
1458
|
'(row history records it as merged into `into`). Both rows must be editable by the caller. Not reversible except by the table history.',
|
|
1213
1459
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1214
1460
|
inputSchema: {
|
|
@@ -1253,6 +1499,173 @@ export function registerDatabaseTools(server) {
|
|
|
1253
1499
|
},
|
|
1254
1500
|
);
|
|
1255
1501
|
|
|
1502
|
+
server.registerTool(
|
|
1503
|
+
'rootr_carry_over',
|
|
1504
|
+
{
|
|
1505
|
+
title: "Carry over a person's previous row into this row",
|
|
1506
|
+
description:
|
|
1507
|
+
'The "이전 정보 가져오기" choice: copy the values of an EARLIER row of the same person (same dedupe value, e.g. phone) ' +
|
|
1508
|
+
'INTO row `rowId`, following the dedupe column\'s dedupeCarry settings (excluded columns such as the entry date, ' +
|
|
1509
|
+
'marks such as 재등록 여부=true / 구분 A→A(재등록)). The earlier row is NOT changed and nothing is linked. ' +
|
|
1510
|
+
'Without `from` every earlier row with the same value is used (sources "all": per column the most recent row that has a value; ' +
|
|
1511
|
+
'sources "latest": only the latest row). The response lists the columns that actually changed (copied, changedCount, ' +
|
|
1512
|
+
'nothingChanged) and an opId for rootr_dedupe_undo. Tables whose dedupeCarry action is carry (or auto) do this by themselves on every write path — use this for tables without it or to pick a ' +
|
|
1513
|
+
'specific earlier row. Only rows the caller can see are used.',
|
|
1514
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1515
|
+
inputSchema: {
|
|
1516
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1517
|
+
rowId: z.string().describe('The new row that receives the values'),
|
|
1518
|
+
from: z.string().optional().describe('The earlier row to copy from (default: latest row with the same dedupe value)'),
|
|
1519
|
+
},
|
|
1520
|
+
},
|
|
1521
|
+
async ({ databaseId, rowId, from }) => {
|
|
1522
|
+
try {
|
|
1523
|
+
const client = makeClient();
|
|
1524
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1525
|
+
return jsonResult(await client.carryOver(id, rowId, from));
|
|
1526
|
+
} catch (err) {
|
|
1527
|
+
return errorResult(err);
|
|
1528
|
+
}
|
|
1529
|
+
},
|
|
1530
|
+
);
|
|
1531
|
+
|
|
1532
|
+
server.registerTool(
|
|
1533
|
+
'rootr_dedupe_preview',
|
|
1534
|
+
{
|
|
1535
|
+
title: 'Preview how a row and its same-value rows would be carried/merged',
|
|
1536
|
+
description:
|
|
1537
|
+
'Read-only. For row `rowId` of a Rootr (루터) DATABASE with a dedupe column, shows what the table\'s duplicate rule (dedupeCarry on ' +
|
|
1538
|
+
'the dedupe column — see rootr_update_column) would do: { action, targetRowId (row that stays), deleted, kept, fromRowIds, columns: ' +
|
|
1539
|
+
'[{ column, strategy, before, after, from }], marked, pending, nothingChanged }. `rules` overrides the table rule for this preview ' +
|
|
1540
|
+
'only (same shape: action, sources, strategy, columns, keepNew, representative, representativeWhen …). 404 when no other row has ' +
|
|
1541
|
+
'the same value. Apply with rootr_dedupe_apply.',
|
|
1542
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
1543
|
+
inputSchema: {
|
|
1544
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1545
|
+
rowId: z.string().describe('The (new) row whose duplicates to handle'),
|
|
1546
|
+
rules: z.record(z.any()).optional().describe('Override the table rule for this call (dedupeCarry shape)'),
|
|
1547
|
+
fromRowIds: z.array(z.string()).optional().describe('Limit the earlier rows used as sources'),
|
|
1548
|
+
},
|
|
1549
|
+
},
|
|
1550
|
+
async ({ databaseId, rowId, rules, fromRowIds }) => {
|
|
1551
|
+
try {
|
|
1552
|
+
const client = makeClient();
|
|
1553
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1554
|
+
return jsonResult(await client.dedupe(id, rowId, { rules, fromRowIds, dryRun: true }));
|
|
1555
|
+
} catch (err) {
|
|
1556
|
+
return errorResult(err);
|
|
1557
|
+
}
|
|
1558
|
+
},
|
|
1559
|
+
);
|
|
1560
|
+
|
|
1561
|
+
server.registerTool(
|
|
1562
|
+
'rootr_dedupe_apply',
|
|
1563
|
+
{
|
|
1564
|
+
title: "Apply the table's duplicate rule to a row (carry / merge into the earlier row / merge all)",
|
|
1565
|
+
description:
|
|
1566
|
+
'Writes. Same input as rootr_dedupe_preview; applies it: carry copies into the row, merge_into_old fills the representative earlier ' +
|
|
1567
|
+
'row and deletes this row (unless keepNew), merge_all keeps one representative row and deletes the rest (unless keepNew). Returns ' +
|
|
1568
|
+
'the same fields plus `opId` — undo the whole operation (restores cells, recreates deleted rows) with rootr_dedupe_undo. Preview ' +
|
|
1569
|
+
'first when deleting rows. The caller must be able to edit the kept row and delete the others.',
|
|
1570
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1571
|
+
inputSchema: {
|
|
1572
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1573
|
+
rowId: z.string().describe('The (new) row whose duplicates to handle'),
|
|
1574
|
+
action: z.enum(['carry', 'merge_into_old', 'merge_all']).optional().describe('Override the action (default: the table rule; ask → askDefault or carry)'),
|
|
1575
|
+
rules: z.record(z.any()).optional().describe('Override the table rule for this call (dedupeCarry shape)'),
|
|
1576
|
+
fromRowIds: z.array(z.string()).optional().describe('Limit the earlier rows used as sources'),
|
|
1577
|
+
targetRowId: z.string().optional().describe('merge_into_old: the earlier row that stays'),
|
|
1578
|
+
},
|
|
1579
|
+
},
|
|
1580
|
+
async ({ databaseId, rowId, action, rules, fromRowIds, targetRowId }) => {
|
|
1581
|
+
try {
|
|
1582
|
+
const client = makeClient();
|
|
1583
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1584
|
+
return jsonResult(await client.dedupe(id, rowId, { action, rules, fromRowIds, targetRowId }));
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
return errorResult(err);
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
);
|
|
1590
|
+
|
|
1591
|
+
server.registerTool(
|
|
1592
|
+
'rootr_dedupe_undo',
|
|
1593
|
+
{
|
|
1594
|
+
title: 'Undo one duplicate-handling operation',
|
|
1595
|
+
description:
|
|
1596
|
+
'Undo a carry/merge by its `opId` (returned by rootr_dedupe_apply, rootr_carry_over, rootr_merge_rows, row writes that ' +
|
|
1597
|
+
'auto-handled a duplicate as `deduped.opId`, and recorded in row history as via "dedupe:<opId>"). Cells it changed go back to ' +
|
|
1598
|
+
'their values before the operation and rows it deleted are recreated with the same id. A second undo is refused.',
|
|
1599
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1600
|
+
inputSchema: {
|
|
1601
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1602
|
+
opId: z.string().describe('The operation id'),
|
|
1603
|
+
},
|
|
1604
|
+
},
|
|
1605
|
+
async ({ databaseId, opId }) => {
|
|
1606
|
+
try {
|
|
1607
|
+
const client = makeClient();
|
|
1608
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1609
|
+
return jsonResult(await client.dedupeUndo(id, opId));
|
|
1610
|
+
} catch (err) {
|
|
1611
|
+
return errorResult(err);
|
|
1612
|
+
}
|
|
1613
|
+
},
|
|
1614
|
+
);
|
|
1615
|
+
|
|
1616
|
+
server.registerTool(
|
|
1617
|
+
'rootr_rule_violations',
|
|
1618
|
+
{
|
|
1619
|
+
title: 'List existing values that break column validation rules',
|
|
1620
|
+
description:
|
|
1621
|
+
'Read-only. Lists cells of a Rootr (루터) DATABASE whose CURRENT value breaks the column\'s validate rule (rootr_update_column ' +
|
|
1622
|
+
'validate: pattern / range / date / birthdate). Rules only check new writes, so values entered before the rule (or imported) ' +
|
|
1623
|
+
'stay as they were — use this to find and fix them. Returns { checkedColumns, total, items: [{ rowId, title, propName, value, message }] }.',
|
|
1624
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
1625
|
+
inputSchema: {
|
|
1626
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1627
|
+
limit: z.number().int().min(1).max(1000).optional().describe('Max items (default 200)'),
|
|
1628
|
+
},
|
|
1629
|
+
},
|
|
1630
|
+
async ({ databaseId, limit }) => {
|
|
1631
|
+
try {
|
|
1632
|
+
const client = makeClient();
|
|
1633
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1634
|
+
return jsonResult(await client.ruleViolations(id, { limit }));
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
return errorResult(err);
|
|
1637
|
+
}
|
|
1638
|
+
},
|
|
1639
|
+
);
|
|
1640
|
+
|
|
1641
|
+
server.registerTool(
|
|
1642
|
+
'rootr_test_value_rule',
|
|
1643
|
+
{
|
|
1644
|
+
title: 'Try a column validation rule on sample values (or list example patterns)',
|
|
1645
|
+
description:
|
|
1646
|
+
'Read-only, saves nothing. Runs a validate rule (the same shape rootr_update_column takes, e.g. { kind: "pattern", pattern: ' +
|
|
1647
|
+
'"\\\\+\\\\d{1,3} .+", message: "…" }) against sample values and returns { rule, results: [{ value, ok, message? }] } — use it to ' +
|
|
1648
|
+
'check a pattern before putting it on a column. `presets: true` returns example patterns to start from (email, URL, ' +
|
|
1649
|
+
'international / KR / US phone, postal codes, digits only, …); formats differ per country and company, so adapt them.',
|
|
1650
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
1651
|
+
inputSchema: {
|
|
1652
|
+
rule: z.record(z.any()).optional().describe('The validate rule to try (omit with presets: true)'),
|
|
1653
|
+
values: z.array(z.union([z.string(), z.number()])).max(20).optional().describe('Up to 20 sample values'),
|
|
1654
|
+
type: z.string().optional().describe('Column type the rule is for (default "text"; "number" for range)'),
|
|
1655
|
+
presets: z.boolean().optional().describe('true = list example patterns instead'),
|
|
1656
|
+
},
|
|
1657
|
+
},
|
|
1658
|
+
async ({ rule, values, type, presets }) => {
|
|
1659
|
+
try {
|
|
1660
|
+
const client = makeClient();
|
|
1661
|
+
if (presets || !rule) return jsonResult(await client.valueRulePresets());
|
|
1662
|
+
return jsonResult(await client.testValueRule(rule, values ?? [], type));
|
|
1663
|
+
} catch (err) {
|
|
1664
|
+
return errorResult(err);
|
|
1665
|
+
}
|
|
1666
|
+
},
|
|
1667
|
+
);
|
|
1668
|
+
|
|
1256
1669
|
// ── ROW-HISTORY / RESTORE(F6·F7·G3) — 행·표 이력과 되돌리기 ─────────────────
|
|
1257
1670
|
|
|
1258
1671
|
server.registerTool(
|
|
@@ -480,7 +480,9 @@ export function registerDocumentTools(server) {
|
|
|
480
480
|
'Partially update a published Rootr (루터) document\'s public settings — only the fields you pass are ' +
|
|
481
481
|
'changed, everything else (including slug) is left as-is. Examples: {"isPublic": false} to unpublish; ' +
|
|
482
482
|
'{"allowCommits": true} to let public readers propose edits; {"skin": "tech-dark"} or ' +
|
|
483
|
-
'{"layout": {"cover": true}} to restyle the public page; {"
|
|
483
|
+
'{"layout": {"cover": true}} to restyle the public page; {"seoDescription": "...", "seoImage": "<attachment id>"} ' +
|
|
484
|
+
'to set the search-result description and link-preview image (search engines are pinged right away); ' +
|
|
485
|
+
'{"expiresAt": "2026-12-31T00:00:00Z"} to set an ' +
|
|
484
486
|
'auto-close date, or {"expiresAt": null} to clear it. ' +
|
|
485
487
|
'Owner-level action (MANAGE): works with an account key (PAT), not with workspace-scoped/connector tokens.',
|
|
486
488
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
@@ -510,6 +512,20 @@ export function registerDocumentTools(server) {
|
|
|
510
512
|
.boolean()
|
|
511
513
|
.optional()
|
|
512
514
|
.describe('true hides the page from search engines (Team plan and up)'),
|
|
515
|
+
seoDescription: z
|
|
516
|
+
.union([z.string().max(300), z.null()])
|
|
517
|
+
.optional()
|
|
518
|
+
.describe(
|
|
519
|
+
'Search-result / link-preview description (meta description, og:description). Aim for ~160 characters ' +
|
|
520
|
+
'that say what the page covers with the words people search for. null or "" falls back to the opening text.',
|
|
521
|
+
),
|
|
522
|
+
seoImage: z
|
|
523
|
+
.union([z.string(), z.null()])
|
|
524
|
+
.optional()
|
|
525
|
+
.describe(
|
|
526
|
+
'Cover image for link previews (og:image): an image attachment id from the same workspace — upload one ' +
|
|
527
|
+
'with rootr_upload_attachment first. 1200×630 landscape fits best. null removes it (the Rootr title card is used).',
|
|
528
|
+
),
|
|
513
529
|
skin: z
|
|
514
530
|
.enum([
|
|
515
531
|
'classic',
|