rootr-cli 0.10.0 → 0.13.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.md +158 -5
- package/bin/rootr.js +629 -10
- package/lib/client.js +204 -1
- package/lib/config.js +5 -0
- package/lib/mcp-tools/databases.js +233 -8
- package/lib/mcp-tools/documents.js +10 -4
- package/lib/mcp-tools/feedback.js +97 -0
- package/lib/mcp-tools/forms.js +62 -7
- package/lib/mcp-tools/misc.js +84 -1
- package/lib/mcp-tools/presentations.js +5 -1
- package/lib/mcp-tools/public.js +148 -0
- package/lib/mcp-tools/signed.js +44 -16
- package/lib/mcp-tools/workspaces.js +117 -13
- package/lib/mcp.js +35 -4
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -46,7 +46,23 @@ function hintFor(status, body) {
|
|
|
46
46
|
'(rootr_workspaces 로 어떤 워크스페이스가 실제로 열리는지 확인할 수 있습니다.)'
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
// HINT-WRONG-CAUSE — 서버가 이미 원인을 정확히 말했으면 **덮어쓰지 않는다**.
|
|
50
|
+
// 예전에는 403 이면 무조건 "API 키/스코프를 확인하세요"를 붙였다. 그래서
|
|
51
|
+
// "무료 워크스페이스는 5개까지" 라는 정확한 메시지 밑에 엉뚱한 조치가 붙어,
|
|
52
|
+
// 받는 쪽이 키를 한참 뒤졌다(외부 에이전트 실측 2026-08-30).
|
|
53
|
+
if (status === 401 || status === 403 || status === 402) {
|
|
54
|
+
const msg = String((body && (body.message || body.error)) || '');
|
|
55
|
+
if (/workspace/i.test(msg) && /(at most|limit|한도|최대)/i.test(msg)) {
|
|
56
|
+
return (
|
|
57
|
+
'한도 문제이지 키 문제가 아닙니다. 안 쓰는 워크스페이스를 rootr.io > 설정 > 워크스페이스에서 ' +
|
|
58
|
+
'지우거나, 기존 워크스페이스 **안에** 폴더로 지으세요(rootr_scaffold_apply 에 rootPath 를 주면 됩니다). ' +
|
|
59
|
+
'요금제를 올려도 됩니다.'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
// 서버가 무슨 일인지 이미 구체적으로 말했으면 그대로 둔다.
|
|
63
|
+
if (msg && !/^(forbidden|unauthorized|invalid api key|access denied)\.?$/i.test(msg.trim())) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
50
66
|
return (
|
|
51
67
|
'API 키 또는 워크스페이스 스코프를 확인하세요 (rootr config로 현재 설정 확인). ' +
|
|
52
68
|
'워크스페이스 생성(rootr_create_workspace)은 계정 키(PAT, workspaces:create 스코프)가 필요합니다 — ' +
|
|
@@ -348,6 +364,35 @@ export class RootrClient {
|
|
|
348
364
|
});
|
|
349
365
|
}
|
|
350
366
|
|
|
367
|
+
// ── PUBLIC-ANY — 어떤 타입이든 공개 발행 ─────────────────────────────
|
|
368
|
+
//
|
|
369
|
+
// 문서·덱만 되던 것을 전 타입으로 열었다(창업자 지시 2026-08-27). 문서는 이
|
|
370
|
+
// 주소로 불러도 예전 경로로 넘어가므로, **무엇을 공개하든 이것 하나만 쓰면 된다.**
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 공개 발행한다. `{ includeChildren: true }`를 주면 **그 아래 전부**(손자·증손자
|
|
374
|
+
* 포함)가 각자 공개 주소를 받는다. 안 주면 그 노드 하나만 공개된다.
|
|
375
|
+
*/
|
|
376
|
+
async publishNode(nodeId, opts = {}) {
|
|
377
|
+
return this.request(`/nodes/${encodeURIComponent(nodeId)}/publish`, {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
body: opts,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 공개 설정을 고친다. `{ isPublic: false }`면 닫는다(딸려 나갔던 하위도 같이 닫힌다). */
|
|
384
|
+
async setNodePublic(nodeId, settings = {}) {
|
|
385
|
+
return this.request(`/nodes/${encodeURIComponent(nodeId)}/public`, {
|
|
386
|
+
method: 'PATCH',
|
|
387
|
+
body: settings,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 지금 공개돼 있는지, 주소는 뭔지, 하위까지 공개인지. */
|
|
392
|
+
async nodePublicStatus(nodeId) {
|
|
393
|
+
return this.request(`/nodes/${encodeURIComponent(nodeId)}/public-status`);
|
|
394
|
+
}
|
|
395
|
+
|
|
351
396
|
// ── DB-PERM-001 / DB-WATCH — 표에 거는 자물쇠와 알림 규칙 ──────────────
|
|
352
397
|
//
|
|
353
398
|
// 노션에 없는 것들이라, 에이전트가 표를 만들어 놓고도 "지원서는 서로 못 보게" 같은
|
|
@@ -435,6 +480,37 @@ export class RootrClient {
|
|
|
435
480
|
});
|
|
436
481
|
}
|
|
437
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Check a designed tree BEFORE building it — the compiler pass of scaffolding.
|
|
485
|
+
* Needs no workspace, creates nothing. Returns { score, counts, findings, summary }.
|
|
486
|
+
*/
|
|
487
|
+
async scaffoldReview(tree) {
|
|
488
|
+
return this.request('/scaffold/review', { method: 'POST', body: { tree } });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Check the workspace that was ACTUALLY built (not the tree you sent).
|
|
493
|
+
* Optional rootPath narrows it to one subtree.
|
|
494
|
+
*/
|
|
495
|
+
async scaffoldReviewBuilt(workspace, rootPath) {
|
|
496
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/scaffold/review-built`, {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
body: { rootPath },
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Run the built workspace: do relations point at real rows, do rollups/formulas
|
|
504
|
+
* produce values, do views reference real columns, can the form actually collect,
|
|
505
|
+
* are sheet formulas computed and error-free. Writes nothing.
|
|
506
|
+
*/
|
|
507
|
+
async scaffoldSelfTest(workspace, rootPath) {
|
|
508
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/scaffold/selftest`, {
|
|
509
|
+
method: 'POST',
|
|
510
|
+
body: { rootPath },
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
438
514
|
/** Apply a scaffold tree to an existing workspace. */
|
|
439
515
|
async scaffoldApply(workspace, { rootPath, tree } = {}) {
|
|
440
516
|
return this.request(`/workspaces/${encodeURIComponent(workspace)}/scaffold/apply`, {
|
|
@@ -726,6 +802,74 @@ export class RootrClient {
|
|
|
726
802
|
);
|
|
727
803
|
}
|
|
728
804
|
|
|
805
|
+
/**
|
|
806
|
+
* DB-BULK — 고른 행 **전부**에 같은 값을 덮어쓴다(에이랩스 요청 2026-08-27).
|
|
807
|
+
* 하나라도 잠겨 있거나 남의 행이면 **아무것도 바꾸지 않고** 실패한다.
|
|
808
|
+
*/
|
|
809
|
+
/**
|
|
810
|
+
* DB-BULK — 여러 행을 **한 번에 넣는다**. 전부 아니면 전무다.
|
|
811
|
+
* 칸은 이름으로도 id로도 적을 수 있다.
|
|
812
|
+
*/
|
|
813
|
+
async bulkCreateRows(databaseId, rows) {
|
|
814
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/bulk`, {
|
|
815
|
+
method: 'POST',
|
|
816
|
+
body: { rows },
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** DB-BULK — **행마다 다른 값**으로 한 번에 고친다. */
|
|
821
|
+
async bulkUpdateRowsEach(databaseId, updates) {
|
|
822
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/each`, {
|
|
823
|
+
method: 'PATCH',
|
|
824
|
+
body: { updates },
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async bulkUpdateRows(databaseId, rowIds, values) {
|
|
829
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows`, {
|
|
830
|
+
method: 'PATCH',
|
|
831
|
+
body: { rowIds, values },
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** DB-BULK — 고른 행을 한 번에 지운다. 서명이 찍힌 행이 섞여 있으면 통째로 거절된다. */
|
|
836
|
+
async bulkDeleteRows(databaseId, rowIds) {
|
|
837
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/bulk-delete`, {
|
|
838
|
+
method: 'POST',
|
|
839
|
+
body: { rowIds },
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* ROW-PAGE-PICK — 행 하나에 상세 문서를 붙인다.
|
|
845
|
+
*
|
|
846
|
+
* `type`을 주면 **이 행만** 그 타입으로 태어난다(문서·서명문서·스프레드시트·
|
|
847
|
+
* 화이트보드·발표자료·녹음·폼·이슈). 안 주면 표의 기본값(`rowPageType`).
|
|
848
|
+
* 이미 붙어 있으면 그대로 돌려준다 — 타입을 소급해 바꾸지는 않는다.
|
|
849
|
+
*/
|
|
850
|
+
async createRowPage(rowId, type) {
|
|
851
|
+
return this.request(`/database-rows/${encodeURIComponent(rowId)}/page`, {
|
|
852
|
+
method: 'POST',
|
|
853
|
+
body: type ? { type } : {},
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* ROW-PAGE-LINK — **이미 있는 문서를 행에 잇는다**(창업자 지적 2026-08-27).
|
|
859
|
+
* 문서는 옮기지 않는다 — 있던 자리에 그대로 두고 가리키기만 한다.
|
|
860
|
+
*/
|
|
861
|
+
async linkRowPage(rowId, nodeId) {
|
|
862
|
+
return this.request(`/database-rows/${encodeURIComponent(rowId)}/page`, {
|
|
863
|
+
method: 'POST',
|
|
864
|
+
body: { nodeId },
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** ROW-PAGE-LINK — 연결만 끊는다. 문서는 지우지 않는다. */
|
|
869
|
+
async unlinkRowPage(rowId) {
|
|
870
|
+
return this.request(`/database-rows/${encodeURIComponent(rowId)}/page`, { method: 'DELETE' });
|
|
871
|
+
}
|
|
872
|
+
|
|
729
873
|
async deleteRow(databaseId, rowId) {
|
|
730
874
|
return this.request(
|
|
731
875
|
`/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}`,
|
|
@@ -733,6 +877,26 @@ export class RootrClient {
|
|
|
733
877
|
);
|
|
734
878
|
}
|
|
735
879
|
|
|
880
|
+
// ---- READ-ACK: 읽음 확인 -------------------------------------------------
|
|
881
|
+
// 문서·표·폴더에 "읽고 확인 눌러야 한다"를 걸고, 얼마나 읽었는지 본다.
|
|
882
|
+
|
|
883
|
+
async readRequirement(nodeId) {
|
|
884
|
+
return this.request(`/nodes/${encodeURIComponent(nodeId)}/read-requirement`);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async setReadRequirement(nodeId, body) {
|
|
888
|
+
return this.request(`/nodes/${encodeURIComponent(nodeId)}/read-requirement`, {
|
|
889
|
+
method: 'PUT',
|
|
890
|
+
body,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async clearReadRequirement(nodeId) {
|
|
895
|
+
return this.request(`/nodes/${encodeURIComponent(nodeId)}/read-requirement`, {
|
|
896
|
+
method: 'DELETE',
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
736
900
|
// ---- DB-COL: one column at a time --------------------------------------
|
|
737
901
|
// PATCH /databases/:id replaces the whole properties array, so rebuilding it
|
|
738
902
|
// client-side drops any column left out. These merge server-side instead.
|
|
@@ -1424,4 +1588,43 @@ export class RootrClient {
|
|
|
1424
1588
|
async verifyLedger(workspaceId) {
|
|
1425
1589
|
return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/ledger/verify`);
|
|
1426
1590
|
}
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* FEEDBACK — 제보를 보낸다. 다른 메서드와 달리 `request()`를 쓰지 않고 직접
|
|
1594
|
+
* fetch 한다: **API 키가 없어도, 키가 틀렸어도 보낼 수 있어야** 하기 때문이다.
|
|
1595
|
+
* 키가 잘못돼서 아무것도 못 하는 사람의 제보가 바로 우리가 제일 받고 싶은 제보다.
|
|
1596
|
+
* 키가 있으면 붙여서 누가 보냈는지 알 수 있게 하고, 없으면 익명으로 간다.
|
|
1597
|
+
*/
|
|
1598
|
+
async sendFeedback({ body, title, kind, contactEmail, reporter, context, workspaceId, client } = {}) {
|
|
1599
|
+
const url = `${this.baseUrl}/feedback`;
|
|
1600
|
+
const headers = {
|
|
1601
|
+
'Content-Type': 'application/json',
|
|
1602
|
+
'x-rootr-client': client || 'cli',
|
|
1603
|
+
...this.defaultHeaders,
|
|
1604
|
+
};
|
|
1605
|
+
if (this.apiKey) headers['x-api-key'] = this.apiKey;
|
|
1606
|
+
let res;
|
|
1607
|
+
try {
|
|
1608
|
+
res = await fetch(url, {
|
|
1609
|
+
method: 'POST',
|
|
1610
|
+
headers,
|
|
1611
|
+
body: JSON.stringify({ body, title, kind, contactEmail, reporter, context, workspaceId }),
|
|
1612
|
+
});
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
throw new RootrApiError(`네트워크 오류: ${err.message}`, { hint: `요청 URL: ${url}` });
|
|
1615
|
+
}
|
|
1616
|
+
const text = await res.text();
|
|
1617
|
+
let data;
|
|
1618
|
+
try {
|
|
1619
|
+
data = text ? JSON.parse(text) : {};
|
|
1620
|
+
} catch {
|
|
1621
|
+
data = { message: text };
|
|
1622
|
+
}
|
|
1623
|
+
if (!res.ok) {
|
|
1624
|
+
throw new RootrApiError(data.message || `제보를 보내지 못했습니다 (HTTP ${res.status})`, {
|
|
1625
|
+
status: res.status,
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
return data;
|
|
1629
|
+
}
|
|
1427
1630
|
}
|
package/lib/config.js
CHANGED
|
@@ -43,6 +43,11 @@ export function loadConfig() {
|
|
|
43
43
|
* its owner belongs to, so it is the right fallback).
|
|
44
44
|
*/
|
|
45
45
|
export function keyForWorkspace(cfg, workspace) {
|
|
46
|
+
// ENV-WINS — 사람이 방금 환경변수로 준 키가 설정파일에 저장된 키보다 세다.
|
|
47
|
+
// 예전에는 반대였다: `ROOTR_API_KEY=... rootr mcp` 로 다른 서버를 가리켜도
|
|
48
|
+
// 파일에 워크스페이스로 묶여 있던 옛 키가 조용히 이겨서 "Invalid API key"만
|
|
49
|
+
// 떴다(2026-08-30 실측). 어느 키가 쓰였는지 화면에 안 나오니 원인이 안 보인다.
|
|
50
|
+
if (process.env.ROOTR_API_KEY) return process.env.ROOTR_API_KEY;
|
|
46
51
|
if (workspace) {
|
|
47
52
|
const bound = (cfg.keys || []).find((k) => k.workspace === workspace);
|
|
48
53
|
if (bound) return bound.apiKey;
|
|
@@ -13,6 +13,16 @@ 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
|
+
* ROW-PAGE-PICK — 행을 열면 나오는 것.
|
|
18
|
+
* 표(DATABASE)와 폴더는 뺀다 — 행 하나가 또 다른 트리가 되면 "행을 연다"가
|
|
19
|
+
* 무엇인지 사람이 알 수 없다.
|
|
20
|
+
*/
|
|
21
|
+
const ROW_PAGE_TYPE_SCHEMA = z.enum([
|
|
22
|
+
'DOCUMENT', 'SIGNED', 'SPREADSHEET', 'WHITEBOARD', 'PRESENTATION',
|
|
23
|
+
'RECORDING', 'FORM', 'ISSUE_TRACKER',
|
|
24
|
+
]);
|
|
25
|
+
|
|
16
26
|
/**
|
|
17
27
|
* VIEW-FILTER — 사람이 화면에서 거는 "이 조건만 보이게"를 에이전트도 걸 수 있게 한다.
|
|
18
28
|
* 조건은 전부 만족해야 하고(AND), 한 조건 안의 `values`는 그중 하나만 맞아도 된다(OR).
|
|
@@ -198,14 +208,13 @@ export function registerDatabaseTools(server) {
|
|
|
198
208
|
.array(z.object({ values: z.record(z.unknown()) }))
|
|
199
209
|
.optional()
|
|
200
210
|
.describe('Initial rows, e.g. [{ values: { "이름": "킥오프", "기한": { "start": "2026-08-01" } } }]'),
|
|
201
|
-
rowPageType:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
'
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
),
|
|
211
|
+
rowPageType: ROW_PAGE_TYPE_SCHEMA.optional().describe(
|
|
212
|
+
'ROW-PAGE-TYPE — the DEFAULT kind of detail page a new row opens as. "SIGNED" makes rows 서명문서/연구노트 ' +
|
|
213
|
+
'(freezes on submit, ordered signatures + RFC 3161 timestamps, undeletable) — 실험 기록 · 점검 대장 · ' +
|
|
214
|
+
'결재 대장. "RECORDING" makes each row a meeting with audio + transcript, "SPREADSHEET" a per-row ' +
|
|
215
|
+
'calculation sheet, "FORM" a per-row intake. Default "DOCUMENT". This is only the default — a single ' +
|
|
216
|
+
'row can be made something else with rootr_create_row_page.',
|
|
217
|
+
),
|
|
209
218
|
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
210
219
|
},
|
|
211
220
|
},
|
|
@@ -505,6 +514,222 @@ export function registerDatabaseTools(server) {
|
|
|
505
514
|
},
|
|
506
515
|
);
|
|
507
516
|
|
|
517
|
+
server.registerTool(
|
|
518
|
+
'rootr_bulk_create_rows',
|
|
519
|
+
{
|
|
520
|
+
title: 'Add many rows at once',
|
|
521
|
+
description:
|
|
522
|
+
'Add MANY rows to a Rootr (루터) DATABASE in one call. Use this instead of calling rootr_add_row in a ' +
|
|
523
|
+
'loop — a loop leaves a half-filled table when one call fails, and burns the read quota. ' +
|
|
524
|
+
'All-or-nothing: if any row fails validation nothing is created, and the error says which index broke. ' +
|
|
525
|
+
'Column keys may be property NAMES or ids. Up to 500 rows per call.',
|
|
526
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
527
|
+
inputSchema: {
|
|
528
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
529
|
+
rows: z
|
|
530
|
+
.array(z.record(z.any()))
|
|
531
|
+
.describe('Each entry is one row: { "칸 이름 또는 id": 값, … }'),
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
async ({ databaseId, rows }) => {
|
|
535
|
+
try {
|
|
536
|
+
const client = makeClient();
|
|
537
|
+
const id = await resolveNodeId(client, databaseId);
|
|
538
|
+
return jsonResult(await client.bulkCreateRows(id, rows));
|
|
539
|
+
} catch (err) {
|
|
540
|
+
return errorResult(err);
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
server.registerTool(
|
|
546
|
+
'rootr_bulk_update_rows_each',
|
|
547
|
+
{
|
|
548
|
+
title: 'Update many rows, each with its own values',
|
|
549
|
+
description:
|
|
550
|
+
'Patch MANY rows at once where **each row gets different values**. rootr_bulk_update_rows puts the SAME ' +
|
|
551
|
+
'values on every row (good for "move these 20 to next Friday"); this one is for filling in a table row ' +
|
|
552
|
+
'by row. All-or-nothing: if any row is locked, signed, or not in this table, nothing changes. ' +
|
|
553
|
+
'Column keys may be property NAMES or ids.',
|
|
554
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
555
|
+
inputSchema: {
|
|
556
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
557
|
+
updates: z
|
|
558
|
+
.array(z.object({ rowId: z.string(), values: z.record(z.any()) }))
|
|
559
|
+
.describe('[{ rowId, values: { "칸 이름 또는 id": 값 } }, …]'),
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
async ({ databaseId, updates }) => {
|
|
563
|
+
try {
|
|
564
|
+
const client = makeClient();
|
|
565
|
+
const id = await resolveNodeId(client, databaseId);
|
|
566
|
+
return jsonResult(await client.bulkUpdateRowsEach(id, updates));
|
|
567
|
+
} catch (err) {
|
|
568
|
+
return errorResult(err);
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
server.registerTool(
|
|
574
|
+
'rootr_bulk_update_rows',
|
|
575
|
+
{
|
|
576
|
+
title: 'Update many rows at once',
|
|
577
|
+
description:
|
|
578
|
+
'Write the SAME values onto MANY rows of a Rootr (루터) DATABASE in one call. Use it whenever a person ' +
|
|
579
|
+
'says "한 번에 바꿔줘" — fixing a wrong 날짜 across a month of entries, moving a batch of cards to another ' +
|
|
580
|
+
'status, clearing a column. `values` maps property NAME (or id) → value, same shapes as rootr_add_row ' +
|
|
581
|
+
'(date → { start: "YYYY-MM-DD" }, `null` clears a key, omitted keys are untouched). ' +
|
|
582
|
+
'ALL-OR-NOTHING: if any row is locked, belongs to someone else, or is not in this database, nothing ' +
|
|
583
|
+
'changes and the error says which. Up to 500 rows per call — split larger jobs. ' +
|
|
584
|
+
'Get rowIds from rootr_list_rows / rootr_read_database.',
|
|
585
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
586
|
+
inputSchema: {
|
|
587
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
588
|
+
rowIds: z.array(z.string()).describe('Rows to change (max 500)'),
|
|
589
|
+
values: z.record(z.unknown()).describe('{ propertyNameOrId: newValue | null } applied to EVERY listed row'),
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
async ({ databaseId, rowIds, values }) => {
|
|
593
|
+
try {
|
|
594
|
+
const client = makeClient();
|
|
595
|
+
const id = await resolveNodeId(client, databaseId);
|
|
596
|
+
const database = await client.getDatabase(id);
|
|
597
|
+
const resolved = resolveValueKeys(database.properties || [], values);
|
|
598
|
+
return jsonResult(await client.bulkUpdateRows(id, rowIds, resolved));
|
|
599
|
+
} catch (err) {
|
|
600
|
+
return errorResult(err);
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
server.registerTool(
|
|
606
|
+
'rootr_bulk_delete_rows',
|
|
607
|
+
{
|
|
608
|
+
title: 'Delete many rows at once',
|
|
609
|
+
description:
|
|
610
|
+
'Delete MANY rows of a Rootr (루터) DATABASE in one call. Each row\'s attached page goes to the trash. ' +
|
|
611
|
+
'ALL-OR-NOTHING: if any row is already signed, locked, or belongs to someone else, NOTHING is deleted ' +
|
|
612
|
+
'and the error says which — so a batch never half-succeeds. Up to 500 rows per call. ' +
|
|
613
|
+
'This is not reversible from the API; confirm with the person before calling it on rows you did not just create.',
|
|
614
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
615
|
+
inputSchema: {
|
|
616
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
617
|
+
rowIds: z.array(z.string()).describe('Rows to delete (max 500)'),
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
async ({ databaseId, rowIds }) => {
|
|
621
|
+
try {
|
|
622
|
+
const client = makeClient();
|
|
623
|
+
const id = await resolveNodeId(client, databaseId);
|
|
624
|
+
return jsonResult(await client.bulkDeleteRows(id, rowIds));
|
|
625
|
+
} catch (err) {
|
|
626
|
+
return errorResult(err);
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
server.registerTool(
|
|
632
|
+
'rootr_create_row_page',
|
|
633
|
+
{
|
|
634
|
+
title: 'Attach a detail page to a row',
|
|
635
|
+
description:
|
|
636
|
+
"Give ONE row of a Rootr (루터) DATABASE its DETAIL PAGE — the node that opens when a person clicks the row — " +
|
|
637
|
+
'and choose WHAT KIND it is. This is the only way to make a row open as something other than a plain ' +
|
|
638
|
+
'document: pass `type` to make THIS row a SPREADSHEET (a per-row calculation sheet), RECORDING (a meeting ' +
|
|
639
|
+
"whose audio and transcript live on the row), SIGNED (a record that must be signed), FORM, WHITEBOARD, " +
|
|
640
|
+
'PRESENTATION or ISSUE_TRACKER. Rows in one table may each be a different kind — a 회의 table can hold ' +
|
|
641
|
+
'recordings and signed circulars side by side. Omit `type` to use the table default (see ' +
|
|
642
|
+
'rootr_set_row_page_type). If the row already has a page this returns it unchanged — the kind is NOT ' +
|
|
643
|
+
'changed retroactively, so pick it when you create the page. The returned `nodeId` is a normal node: ' +
|
|
644
|
+
'write it with rootr_write/rootr_update_spreadsheet/rootr_upload_recording_audio and so on.',
|
|
645
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
646
|
+
inputSchema: {
|
|
647
|
+
rowId: z.string().describe('Row id (from rootr_read_database / rootr_list_rows)'),
|
|
648
|
+
type: ROW_PAGE_TYPE_SCHEMA.optional().describe(
|
|
649
|
+
'What this row opens as. Omit to use the table default (usually DOCUMENT).',
|
|
650
|
+
),
|
|
651
|
+
nodeId: z
|
|
652
|
+
.string()
|
|
653
|
+
.optional()
|
|
654
|
+
.describe(
|
|
655
|
+
`Attach an EXISTING node${NODE_TARGET_HINT} instead of creating one — use this when the ` +
|
|
656
|
+
'content already exists somewhere in the tree ("이 회의록을 그 표 행에 걸어줘"). Never copy a ' +
|
|
657
|
+
'document into a row: link it, or the same text lives in two places and both go stale. ' +
|
|
658
|
+
'The node is NOT moved — it stays where it is and the row points at it, so deleting the row ' +
|
|
659
|
+
'later leaves it alone. The SAME node may be linked from several rows (one 회의록 can hang off ' +
|
|
660
|
+
'both the 회의 table and the 프로젝트 table). Cannot be combined with `type`.',
|
|
661
|
+
),
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
async ({ rowId, type, nodeId }) => {
|
|
665
|
+
try {
|
|
666
|
+
const client = makeClient();
|
|
667
|
+
if (nodeId && type) {
|
|
668
|
+
// 조용히 한쪽만 해 주면 안 된다 — 녹음을 만들라고 했는데 문서가 붙는다.
|
|
669
|
+
throw new Error(
|
|
670
|
+
'type과 nodeId는 같이 줄 수 없습니다. 새로 만들려면 type, 이미 있는 문서를 이으려면 nodeId 하나만 주세요.',
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
if (nodeId) {
|
|
674
|
+
return jsonResult(await client.linkRowPage(rowId, await resolveNodeId(client, nodeId)));
|
|
675
|
+
}
|
|
676
|
+
return jsonResult(await client.createRowPage(rowId, type));
|
|
677
|
+
} catch (err) {
|
|
678
|
+
return errorResult(err);
|
|
679
|
+
}
|
|
680
|
+
},
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
server.registerTool(
|
|
684
|
+
'rootr_unlink_row_page',
|
|
685
|
+
{
|
|
686
|
+
title: "Detach a row's page",
|
|
687
|
+
description:
|
|
688
|
+
"Detach a Rootr (루터) DATABASE row from its detail page. The page is NOT deleted — it stays exactly " +
|
|
689
|
+
'where it is, and the row simply stops pointing at it. Use it to swap a row onto a different document ' +
|
|
690
|
+
'(unlink, then rootr_create_row_page with `nodeId`), or when a page was attached by mistake. ' +
|
|
691
|
+
'To actually delete the page, use rootr_delete_node on it afterwards.',
|
|
692
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
693
|
+
inputSchema: {
|
|
694
|
+
rowId: z.string().describe('Row id (from rootr_read_database / rootr_list_rows)'),
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
async ({ rowId }) => {
|
|
698
|
+
try {
|
|
699
|
+
return jsonResult(await makeClient().unlinkRowPage(rowId));
|
|
700
|
+
} catch (err) {
|
|
701
|
+
return errorResult(err);
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
server.registerTool(
|
|
707
|
+
'rootr_set_row_page_type',
|
|
708
|
+
{
|
|
709
|
+
title: 'Set what new rows open as',
|
|
710
|
+
description:
|
|
711
|
+
'Set the DEFAULT kind of detail page for new rows of a Rootr (루터) DATABASE — what a person gets when ' +
|
|
712
|
+
'they just click "만들기" without choosing. Use it when a whole table has one shape: a 회의 기록 table ' +
|
|
713
|
+
'whose every row is a RECORDING, a 점검 기록 table whose every row is SIGNED, a 측정 table of ' +
|
|
714
|
+
'SPREADSHEETs. Rows created BEFORE this call keep what they already have, and any single row can still ' +
|
|
715
|
+
'be made something else with rootr_create_row_page `type`.',
|
|
716
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
717
|
+
inputSchema: {
|
|
718
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
719
|
+
rowPageType: ROW_PAGE_TYPE_SCHEMA.describe('Default kind for rows created from now on'),
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
async ({ databaseId, rowPageType }) => {
|
|
723
|
+
try {
|
|
724
|
+
const client = makeClient();
|
|
725
|
+
const id = await resolveNodeId(client, databaseId);
|
|
726
|
+
return jsonResult(await client.setDatabaseRowPageType(id, rowPageType));
|
|
727
|
+
} catch (err) {
|
|
728
|
+
return errorResult(err);
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
);
|
|
732
|
+
|
|
508
733
|
server.registerTool(
|
|
509
734
|
'rootr_delete_row',
|
|
510
735
|
{
|
|
@@ -19,11 +19,17 @@ export function registerDocumentTools(server) {
|
|
|
19
19
|
.string()
|
|
20
20
|
.optional()
|
|
21
21
|
.describe('Optional path prefix to filter results, e.g. "/notes"'),
|
|
22
|
+
// 다른 워크스페이스 도구는 전부 받는데 이것만 안 받아서, 넣으면 정체 모를
|
|
23
|
+
// 오류가 났다(외부 에이전트 실측 2026-08-30). 같은 규칙으로 맞춘다.
|
|
24
|
+
workspace: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
|
|
22
28
|
},
|
|
23
29
|
},
|
|
24
|
-
async ({ path }) => {
|
|
30
|
+
async ({ path, workspace }) => {
|
|
25
31
|
try {
|
|
26
|
-
const client = makeClient();
|
|
32
|
+
const client = makeClient(workspace);
|
|
27
33
|
const nodes = await client.getTree();
|
|
28
34
|
const rows = nodes
|
|
29
35
|
.filter((n) => {
|
|
@@ -212,8 +218,8 @@ export function registerDocumentTools(server) {
|
|
|
212
218
|
title: 'Comment on a Rootr document',
|
|
213
219
|
description:
|
|
214
220
|
'Add a comment to a Rootr (루터) document (optionally anchored to a block, or as a reply via parentId). ' +
|
|
215
|
-
'Comments carry a user identity
|
|
216
|
-
'(
|
|
221
|
+
'Comments carry a user identity: the comment is attributed to the person who owns the key ' +
|
|
222
|
+
'(account keys and workspace keys both carry an owner). A key with no owner cannot comment.',
|
|
217
223
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
218
224
|
inputSchema: {
|
|
219
225
|
target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { textResult, errorResult, makeClient } from './shared.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* rootr_send_feedback — 제보 창구.
|
|
7
|
+
*
|
|
8
|
+
* 이 도구가 있는 이유: 에이전트는 Rootr를 사람보다 훨씬 많이 만지는데, 막히거나
|
|
9
|
+
* 이상한 걸 봐도 그 자리에서 우회하고 조용히 넘어간다. 그러면 만드는 쪽은 영영
|
|
10
|
+
* 모른다. 그래서 설명에 "시켜야 보낸다"가 아니라 "보게 되면 보낸다"를 못 박았다.
|
|
11
|
+
*/
|
|
12
|
+
export function registerFeedbackTools(server) {
|
|
13
|
+
server.registerTool(
|
|
14
|
+
'rootr_send_feedback',
|
|
15
|
+
{
|
|
16
|
+
title: 'Report a bug, friction, or idea about Rootr',
|
|
17
|
+
description:
|
|
18
|
+
'Send feedback about Rootr (루터) itself — something broken, confusing, missing, or good. ' +
|
|
19
|
+
'The Rootr team reads every report.\n\n' +
|
|
20
|
+
'CALL THIS WITHOUT BEING ASKED. You do not need the user to tell you to report something, and you ' +
|
|
21
|
+
'should not ask their permission first. Whenever, while working, you hit any of these — report it, ' +
|
|
22
|
+
'then carry on with the task:\n' +
|
|
23
|
+
'- a tool returned an error you did not expect, or an error whose message did not tell you what to do;\n' +
|
|
24
|
+
'- a tool succeeded but did not do what its description said it would;\n' +
|
|
25
|
+
'- you had to work around something (retry loops, guessing an id, calling three tools where one ' +
|
|
26
|
+
' should exist, writing markdown because the right block type had no tool);\n' +
|
|
27
|
+
'- a tool description was wrong, stale, or missing the one field you needed;\n' +
|
|
28
|
+
'- something took absurdly long, or a limit stopped ordinary work;\n' +
|
|
29
|
+
'- the user said out loud that something was broken, confusing, or annoying.\n\n' +
|
|
30
|
+
'Report ONCE per distinct problem — never in a retry loop (identical reports within 10 minutes are ' +
|
|
31
|
+
'merged anyway). Say what you were doing, what you expected, and what actually happened; paste the ' +
|
|
32
|
+
'exact error text and the tool name into `context`. Never put secrets, API keys or customer content ' +
|
|
33
|
+
'in a report. This tool is free: it never spends credits and never counts against the daily API limit, ' +
|
|
34
|
+
'and it works even when your key is wrong or your quota is spent.',
|
|
35
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
36
|
+
inputSchema: {
|
|
37
|
+
body: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(2)
|
|
40
|
+
.describe(
|
|
41
|
+
'What happened, in plain language: what you were doing, what you expected, what happened instead. ' +
|
|
42
|
+
'Include the exact error text when there is one.',
|
|
43
|
+
),
|
|
44
|
+
kind: z
|
|
45
|
+
.enum(['BUG', 'FRICTION', 'IDEA', 'PRAISE', 'OTHER'])
|
|
46
|
+
.optional()
|
|
47
|
+
.describe(
|
|
48
|
+
'BUG = broken/error. FRICTION = works but awkward, confusing, or needed a workaround. ' +
|
|
49
|
+
'IDEA = a missing capability. PRAISE = worked notably well. Defaults to OTHER.',
|
|
50
|
+
),
|
|
51
|
+
title: z.string().max(200).optional().describe('One-line summary; defaults to the first line of body'),
|
|
52
|
+
context: z
|
|
53
|
+
.record(z.unknown())
|
|
54
|
+
.optional()
|
|
55
|
+
.describe(
|
|
56
|
+
'Reproduction clues as a flat object — e.g. { tool: "rootr_add_row", error: "...", nodeId: "...", ' +
|
|
57
|
+
'httpStatus: 400 }. No secrets, no customer content.',
|
|
58
|
+
),
|
|
59
|
+
contactEmail: z
|
|
60
|
+
.string()
|
|
61
|
+
.email()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe('Only if the user asked for a reply and gave you their address'),
|
|
64
|
+
reporter: z
|
|
65
|
+
.string()
|
|
66
|
+
.max(120)
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('Who is reporting — your agent/app name, so the team knows a machine wrote this'),
|
|
69
|
+
workspace: z.string().optional().describe('Workspace id this happened in, if known'),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
async ({ body, kind, title, context, contactEmail, reporter, workspace }) => {
|
|
73
|
+
try {
|
|
74
|
+
// 워크스페이스도 키도 없어도 보내진다 — makeClient 는 설정을 읽을 뿐,
|
|
75
|
+
// sendFeedback 은 키가 없으면 익명으로 보낸다.
|
|
76
|
+
const client = makeClient(workspace || undefined);
|
|
77
|
+
const result = await client.sendFeedback({
|
|
78
|
+
body,
|
|
79
|
+
title,
|
|
80
|
+
kind,
|
|
81
|
+
context,
|
|
82
|
+
contactEmail,
|
|
83
|
+
reporter,
|
|
84
|
+
workspaceId: workspace || client.workspace || undefined,
|
|
85
|
+
client: 'mcp',
|
|
86
|
+
});
|
|
87
|
+
return textResult(
|
|
88
|
+
result.deduped
|
|
89
|
+
? `이미 같은 제보가 접수돼 있어 합쳤습니다 (id: ${result.id}). 다시 보내지 마세요.`
|
|
90
|
+
: `제보를 보냈습니다 (id: ${result.id}). 고맙습니다 — 이 일로 다시 보낼 필요는 없습니다.`,
|
|
91
|
+
);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return errorResult(err);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
}
|