rootr-cli 0.4.0 → 0.5.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 CHANGED
@@ -206,6 +206,13 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
206
206
 
207
207
  `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 29종:
208
208
 
209
+ 노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
210
+ `recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
211
+ 받습니다 — CLI와 같은 규칙으로 `/`로 시작하면 경로로 해석합니다.
212
+ 즉 `rootr_add_row`에 `databaseId: "/제작 공정/칸반"`을 그대로 넘길 수 있어,
213
+ 경로만 아는 에이전트가 uuid를 찾아 헤맬 필요가 없습니다.
214
+ `rootr_list`·`rootr_search` 출력도 각 줄 끝에 `[노드 id]`를 함께 표시합니다.
215
+
209
216
  #### 문서 (workspace 키, docs:read/docs:write)
210
217
 
211
218
  | 도구 | 설명 |
@@ -282,10 +289,13 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
282
289
  | `rootr_document_versions` | 문서 버전 히스토리 조회 |
283
290
  | `rootr_delete_node` | 노드를 휴지통으로 이동(복구 가능) |
284
291
 
285
- #### 프레젠테이션 (현재 REST 전용 — 전용 MCP 도구는 예정)
292
+ #### 프레젠테이션
286
293
 
287
- 프레젠테이션(PRESENTATION) 노드는 아직 전용 MCP 도구가 없고, **같은 API 키로 REST를
288
- 직접 호출**해 저작합니다 (`x-api-key` 헤더, `docs:write`):
294
+ 프레젠테이션(PRESENTATION)은 전용 MCP 도구(`rootr_create_presentation`,
295
+ `rootr_read_presentation`, `rootr_append_presentation_slides`,
296
+ `rootr_update_presentation_slide`, `rootr_reorder_presentation_slides`,
297
+ `rootr_update_presentation`)로 저작합니다. 아래처럼 **같은 API 키로 REST를 직접
298
+ 호출**해도 됩니다 (`x-api-key` 헤더, `docs:write`):
289
299
 
290
300
  ```bash
291
301
  # 덱 생성 (슬라이드 포함 가능)
@@ -300,6 +310,33 @@ curl -X POST "$BASE/presentations/$ID/slides" -d '{"slides":[...]}' ...
300
310
  curl -X POST "$BASE/presentations/$ID/reorder" -d '{"order":["SLD-002","SLD-001"]}' ...
301
311
  ```
302
312
 
313
+ ##### 슬라이드에 다이어그램 그려 넣기
314
+
315
+ 구조도·업무 흐름도·순서도·데이터 흐름도·상태도는 손으로 SVG를 짜지 말고
316
+ `rootr_create_diagram_slide`에 **형식이 정해진 JSON 스펙**을 넘기세요. 서버가
317
+ 검증하고 그려서 슬라이드에 바로 꽂습니다 — 화면은 물론 PDF·PPTX 내보내기에도
318
+ 그대로 나옵니다. 슬라이드의 `diagram`(mermaid) 필드는 **뷰어가 그리지 않으므로**
319
+ 이 도구를 쓰는 것이 유일한 방법입니다.
320
+
321
+ 종류마다 스펙 모양이 다르니 **`rootr_diagram_schema`로 스키마와 예제를 먼저
322
+ 받아보세요.** 맞지 않는 스펙은 어느 항목이 왜 틀렸는지와 고치는 법을 달아 거절되니,
323
+ 그대로 고쳐 다시 부르면 됩니다.
324
+
325
+ ```bash
326
+ # 스펙 모양 보기 (architecture | workflow | sequence | dataflow | lifecycle)
327
+ curl "$BASE/diagram-types/workflow/schema" -H "x-api-key: $KEY"
328
+
329
+ # 그려서 슬라이드에 꽂기 (slideId를 주면 그 슬라이드를 다시 그린다)
330
+ curl -X POST "$BASE/presentations/$ID/diagram-slides" -H "x-api-key: $KEY" \
331
+ -H 'Content-Type: application/json' \
332
+ -d '{"type":"workflow","spec":{...},"title":"이슈는 검수를 통과해야만 main에 들어간다",
333
+ "blocks":[{"heading":"검수가 관문","body":"..."}]}'
334
+ ```
335
+
336
+ `quality`는 기본 `standard`이고, `showcase`는 손으로 다듬은 대표 그림용 엄격
337
+ 기준이라 `standard`가 통과시키는 스펙도 거절합니다. 그림 속 글자는 지식 그래프에
338
+ 들어가지 않으니 사실은 `title`·`blocks`·`notes`에도 남기세요.
339
+
303
340
  **고품질 덱을 만들 때는 슬라이드 `html` 필드에 1280×720 완결 HTML을 직접 구워
304
341
  넣는 방식을 권장**합니다 — 뷰어가 기본 템플릿 대신 그대로 렌더합니다(완전 자유
305
342
  디자인). 필수 규칙(외부 요청 차단이라 전부 인라인, 이미지=data URI 내장, 폰트
package/lib/client.js CHANGED
@@ -852,6 +852,24 @@ export class RootrClient {
852
852
  });
853
853
  }
854
854
 
855
+ /** Diagram families the server can draw, for discovery. */
856
+ async diagramTypes() {
857
+ return this.request('/diagram-types');
858
+ }
859
+
860
+ /** JSON Schema + a worked example for one diagram family. */
861
+ async diagramSchema(type) {
862
+ return this.request(`/diagram-types/${encodeURIComponent(type)}/schema`);
863
+ }
864
+
865
+ /** Render a typed diagram spec onto a slide (appends, or replaces slideId). */
866
+ async createDiagramSlide(presentationId, payload) {
867
+ return this.request(`/presentations/${encodeURIComponent(presentationId)}/diagram-slides`, {
868
+ method: 'POST',
869
+ body: payload,
870
+ });
871
+ }
872
+
855
873
  async updatePresentationSlide(presentationId, slideId, slide) {
856
874
  return this.request(
857
875
  `/presentations/${encodeURIComponent(presentationId)}/slides/${encodeURIComponent(slideId)}`,
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
 
3
3
  import { z } from 'zod';
4
4
 
5
- import { jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
5
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveOptionalNodeId, NODE_TARGET_HINT } from './shared.js';
6
6
  import { resolveTarget, requireResolved } from '../resolve.js';
7
7
  import { RootrApiError } from '../client.js';
8
8
 
@@ -24,7 +24,7 @@ const uploadInputSchema = {
24
24
  .string()
25
25
  .optional()
26
26
  .describe('Base64-encoded file bytes; use this when the caller has the file in memory instead of on disk'),
27
- nodeId: z.string().optional().describe('Document/node id to associate the attachment with (permission-checked)'),
27
+ nodeId: z.string().optional().describe(`Document/node id${NODE_TARGET_HINT} to associate the attachment with (permission-checked)`),
28
28
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
29
29
  };
30
30
 
@@ -48,6 +48,7 @@ export function registerAttachmentTools(server) {
48
48
  try {
49
49
  const client = makeClient();
50
50
  const ws = requireWorkspace(workspace);
51
+ nodeId = await resolveOptionalNodeId(client, nodeId);
51
52
  const data = readAttachmentBytes({ filePath, base64Data });
52
53
  const { id } = await client.uploadAttachment(ws, { filename, mimeType, nodeId, data });
53
54
  const url = client.attachmentRawUrl(id);
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, resolveOptionalNodeId } from './shared.js';
4
4
 
5
5
  /**
6
6
  * Shared context repeated (shortened) across tool descriptions so an agent
@@ -61,7 +61,7 @@ export function registerCrmTools(server) {
61
61
  inputSchema: {
62
62
  name: z.string().optional().describe('CRM node name'),
63
63
  path: z.string().optional().describe('Full tree path to create at, e.g. "/sales/crm"'),
64
- parentId: z.string().optional().describe('Parent folder/node id to create the CRM under'),
64
+ parentId: z.string().optional().describe(`Parent folder/node id${NODE_TARGET_HINT}`),
65
65
  icon: z.string().optional().describe('Emoji icon for the CRM node'),
66
66
  pipelines: z
67
67
  .array(pipelineShape)
@@ -81,6 +81,7 @@ export function registerCrmTools(server) {
81
81
  try {
82
82
  const ws = requireWorkspace(workspace);
83
83
  const client = makeClient(ws);
84
+ parentId = await resolveOptionalNodeId(client, parentId);
84
85
  const result = await client.createCrm(ws, {
85
86
  name,
86
87
  path,
@@ -111,7 +112,7 @@ export function registerCrmTools(server) {
111
112
  CRM_CONTEXT,
112
113
  annotations: { readOnlyHint: true, destructiveHint: false },
113
114
  inputSchema: {
114
- crmId: z.string().describe('CRM node id'),
115
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
115
116
  includeSummary: z
116
117
  .boolean()
117
118
  .optional()
@@ -121,6 +122,7 @@ export function registerCrmTools(server) {
121
122
  async ({ crmId, includeSummary }) => {
122
123
  try {
123
124
  const client = makeClient();
125
+ crmId = await resolveNodeId(client, crmId);
124
126
  const crm = await client.getCrm(crmId);
125
127
  if (includeSummary) {
126
128
  const summary = await client.getCrmSummary(crmId);
@@ -144,7 +146,7 @@ export function registerCrmTools(server) {
144
146
  CRM_CONTEXT,
145
147
  annotations: { readOnlyHint: false, destructiveHint: false },
146
148
  inputSchema: {
147
- crmId: z.string().describe('CRM node id'),
149
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
148
150
  pipelines: z
149
151
  .array(pipelineShape)
150
152
  .optional()
@@ -157,6 +159,7 @@ export function registerCrmTools(server) {
157
159
  async ({ crmId, pipelines, fields, config, icon }) => {
158
160
  try {
159
161
  const client = makeClient();
162
+ crmId = await resolveNodeId(client, crmId);
160
163
  const result = await client.updateCrm(crmId, {
161
164
  pipelines: coerceJson(pipelines),
162
165
  fields: coerceJson(fields),
@@ -182,7 +185,7 @@ export function registerCrmTools(server) {
182
185
  CRM_CONTEXT,
183
186
  annotations: { readOnlyHint: true, destructiveHint: false },
184
187
  inputSchema: {
185
- crmId: z.string().describe('CRM node id'),
188
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
186
189
  q: z.string().optional().describe('Free-text search (name/domain/etc.)'),
187
190
  ownerId: z.string().optional().describe('Filter by owning user id'),
188
191
  tag: z.string().optional().describe('Filter by tag'),
@@ -194,6 +197,7 @@ export function registerCrmTools(server) {
194
197
  async ({ crmId, q, ownerId, tag, sort, limit, offset }) => {
195
198
  try {
196
199
  const client = makeClient();
200
+ crmId = await resolveNodeId(client, crmId);
197
201
  const result = await client.listCrmCompanies(crmId, { q, ownerId, tag, sort, limit, offset });
198
202
  return jsonResult(result);
199
203
  } catch (err) {
@@ -213,7 +217,7 @@ export function registerCrmTools(server) {
213
217
  CRM_CONTEXT,
214
218
  annotations: { readOnlyHint: false, destructiveHint: false },
215
219
  inputSchema: {
216
- crmId: z.string().describe('CRM node id'),
220
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
217
221
  companyId: z.string().optional().describe('Existing company id to update/delete; omit to create a new company'),
218
222
  delete: z.boolean().optional().describe('If true (with companyId), delete the company instead of updating'),
219
223
  name: z.string().optional().describe('Company name (required when creating)'),
@@ -231,6 +235,7 @@ export function registerCrmTools(server) {
231
235
  async ({ crmId, companyId, delete: doDelete, custom, ...rest }) => {
232
236
  try {
233
237
  const client = makeClient();
238
+ crmId = await resolveNodeId(client, crmId);
234
239
  if (companyId && doDelete) {
235
240
  await client.deleteCrmCompany(companyId);
236
241
  return jsonResult({ deleted: true, companyId });
@@ -283,7 +288,7 @@ export function registerCrmTools(server) {
283
288
  CRM_CONTEXT,
284
289
  annotations: { readOnlyHint: true, destructiveHint: false },
285
290
  inputSchema: {
286
- crmId: z.string().describe('CRM node id'),
291
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
287
292
  q: z.string().optional().describe('Free-text search (name/email/etc.)'),
288
293
  companyId: z.string().optional().describe('Filter by company id'),
289
294
  lifecycleStage: z.string().optional().describe('lead | mql | sql | opportunity | customer | evangelist'),
@@ -298,6 +303,7 @@ export function registerCrmTools(server) {
298
303
  async ({ crmId, q, companyId, lifecycleStage, leadStatus, ownerId, tag, sort, limit, offset }) => {
299
304
  try {
300
305
  const client = makeClient();
306
+ crmId = await resolveNodeId(client, crmId);
301
307
  const result = await client.listCrmContacts(crmId, {
302
308
  q,
303
309
  companyId,
@@ -327,7 +333,7 @@ export function registerCrmTools(server) {
327
333
  CRM_CONTEXT,
328
334
  annotations: { readOnlyHint: false, destructiveHint: false },
329
335
  inputSchema: {
330
- crmId: z.string().describe('CRM node id'),
336
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
331
337
  contactId: z.string().optional().describe('Existing contact id to update/delete; omit to create a new contact'),
332
338
  delete: z.boolean().optional().describe('If true (with contactId), delete the contact instead of updating'),
333
339
  name: z.string().optional().describe('Contact name (required when creating)'),
@@ -353,6 +359,7 @@ export function registerCrmTools(server) {
353
359
  async ({ crmId, contactId, delete: doDelete, custom, ...rest }) => {
354
360
  try {
355
361
  const client = makeClient();
362
+ crmId = await resolveNodeId(client, crmId);
356
363
  if (contactId && doDelete) {
357
364
  await client.deleteCrmContact(contactId);
358
365
  return jsonResult({ deleted: true, contactId });
@@ -401,7 +408,7 @@ export function registerCrmTools(server) {
401
408
  CRM_CONTEXT,
402
409
  annotations: { readOnlyHint: true, destructiveHint: false },
403
410
  inputSchema: {
404
- crmId: z.string().describe('CRM node id'),
411
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
405
412
  q: z.string().optional().describe('Free-text search (deal name/etc.)'),
406
413
  pipelineId: z.string().optional().describe('Filter by pipeline id'),
407
414
  stageId: z.string().optional().describe('Filter by stage id'),
@@ -418,6 +425,7 @@ export function registerCrmTools(server) {
418
425
  async ({ crmId, q, pipelineId, stageId, status, ownerId, companyId, contactId, tag, sort, limit, offset }) => {
419
426
  try {
420
427
  const client = makeClient();
428
+ crmId = await resolveNodeId(client, crmId);
421
429
  const result = await client.listCrmDeals(crmId, {
422
430
  q,
423
431
  pipelineId,
@@ -450,7 +458,7 @@ export function registerCrmTools(server) {
450
458
  CRM_CONTEXT,
451
459
  annotations: { readOnlyHint: false, destructiveHint: false },
452
460
  inputSchema: {
453
- crmId: z.string().describe('CRM node id'),
461
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
454
462
  dealId: z.string().optional().describe('Existing deal id to update/delete; omit to create a new deal'),
455
463
  delete: z.boolean().optional().describe('If true (with dealId), delete the deal instead of updating'),
456
464
  name: z.string().optional().describe('Deal name (required when creating)'),
@@ -478,6 +486,7 @@ export function registerCrmTools(server) {
478
486
  async ({ crmId, dealId, delete: doDelete, contacts, custom, ...rest }) => {
479
487
  try {
480
488
  const client = makeClient();
489
+ crmId = await resolveNodeId(client, crmId);
481
490
  if (dealId && doDelete) {
482
491
  await client.deleteCrmDeal(dealId);
483
492
  return jsonResult({ deleted: true, dealId });
@@ -552,7 +561,7 @@ export function registerCrmTools(server) {
552
561
  CRM_CONTEXT,
553
562
  annotations: { readOnlyHint: false, destructiveHint: false },
554
563
  inputSchema: {
555
- crmId: z.string().describe('CRM node id'),
564
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
556
565
  type: z.string().describe('note | call | email | meeting'),
557
566
  subject: z.string().optional(),
558
567
  body: z.string().optional().describe('Activity body/notes (markdown/plain text)'),
@@ -565,6 +574,7 @@ export function registerCrmTools(server) {
565
574
  async ({ crmId, ...rest }) => {
566
575
  try {
567
576
  const client = makeClient();
577
+ crmId = await resolveNodeId(client, crmId);
568
578
  const result = await client.createCrmActivity(crmId, rest);
569
579
  return jsonResult(result);
570
580
  } catch (err) {
@@ -582,7 +592,7 @@ export function registerCrmTools(server) {
582
592
  CRM_CONTEXT,
583
593
  annotations: { readOnlyHint: true, destructiveHint: false },
584
594
  inputSchema: {
585
- crmId: z.string().describe('CRM node id'),
595
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
586
596
  contactId: z.string().optional(),
587
597
  companyId: z.string().optional(),
588
598
  dealId: z.string().optional(),
@@ -594,6 +604,7 @@ export function registerCrmTools(server) {
594
604
  async ({ crmId, contactId, companyId, dealId, type, limit, offset }) => {
595
605
  try {
596
606
  const client = makeClient();
607
+ crmId = await resolveNodeId(client, crmId);
597
608
  const result = await client.listCrmActivities(crmId, { contactId, companyId, dealId, type, limit, offset });
598
609
  return jsonResult(result);
599
610
  } catch (err) {
@@ -615,7 +626,7 @@ export function registerCrmTools(server) {
615
626
  CRM_CONTEXT,
616
627
  annotations: { readOnlyHint: false, destructiveHint: false },
617
628
  inputSchema: {
618
- crmId: z.string().describe('CRM node id (required when creating; ignored when updating)'),
629
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT} (required when creating; ignored when updating)`),
619
630
  taskId: z.string().optional().describe('Existing task id to update; omit to create a new task'),
620
631
  title: z.string().optional().describe('Task title (required when creating)'),
621
632
  type: z.string().optional().describe('todo | call | email | meeting'),
@@ -630,6 +641,7 @@ export function registerCrmTools(server) {
630
641
  async ({ crmId, taskId, ...rest }) => {
631
642
  try {
632
643
  const client = makeClient();
644
+ crmId = await resolveNodeId(client, crmId);
633
645
  const result = taskId ? await client.updateCrmTask(taskId, rest) : await client.createCrmTask(crmId, rest);
634
646
  return jsonResult(result);
635
647
  } catch (err) {
@@ -648,7 +660,7 @@ export function registerCrmTools(server) {
648
660
  CRM_CONTEXT,
649
661
  annotations: { readOnlyHint: true, destructiveHint: false },
650
662
  inputSchema: {
651
- crmId: z.string().describe('CRM node id'),
663
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
652
664
  status: z.string().optional().describe('open | done | all'),
653
665
  assigneeId: z.string().optional(),
654
666
  due: z.string().optional().describe('overdue | today | upcoming'),
@@ -662,6 +674,7 @@ export function registerCrmTools(server) {
662
674
  async ({ crmId, status, assigneeId, due, contactId, companyId, dealId, limit, offset }) => {
663
675
  try {
664
676
  const client = makeClient();
677
+ crmId = await resolveNodeId(client, crmId);
665
678
  const result = await client.listCrmTasks(crmId, {
666
679
  status,
667
680
  assigneeId,
@@ -693,7 +706,7 @@ export function registerCrmTools(server) {
693
706
  CRM_CONTEXT,
694
707
  annotations: { readOnlyHint: false, destructiveHint: false },
695
708
  inputSchema: {
696
- crmId: z.string().describe('CRM node id'),
709
+ crmId: z.string().describe(`CRM node id${NODE_TARGET_HINT}`),
697
710
  entity: z.string().describe('contact | company | deal'),
698
711
  rows: z.array(z.record(z.unknown())).describe('Array of row objects to import, matching the entity shape'),
699
712
  },
@@ -701,6 +714,7 @@ export function registerCrmTools(server) {
701
714
  async ({ crmId, entity, rows }) => {
702
715
  try {
703
716
  const client = makeClient();
717
+ crmId = await resolveNodeId(client, crmId);
704
718
  const result = await client.importCrm(crmId, { entity, rows: coerceJson(rows) });
705
719
  return jsonResult(result);
706
720
  } catch (err) {
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { jsonResult, errorResult, makeClient } from './shared.js';
3
+ import { jsonResult, errorResult, makeClient, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
4
+
5
+ /** Human text for the `databaseId` param — accepts a path just like the document tools. */
6
+ const DATABASE_TARGET_DESC = `DATABASE node id${NODE_TARGET_HINT} (e.g. "/제작 공정/칸반")`;
4
7
 
5
8
  /**
6
9
  * Resolve a `{propertyNameOrId: value}` map to `{propertyId: value}` using the
@@ -40,15 +43,16 @@ export function registerDatabaseTools(server) {
40
43
  'property ids/names and, for board/kanban views, which select column holds the status. Only works on DATABASE nodes.',
41
44
  annotations: { readOnlyHint: true, destructiveHint: false },
42
45
  inputSchema: {
43
- databaseId: z.string().describe('DATABASE node id'),
46
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
44
47
  },
45
48
  },
46
49
  async ({ databaseId }) => {
47
50
  try {
48
51
  const client = makeClient();
52
+ const id = await resolveNodeId(client, databaseId);
49
53
  const [database, rows] = await Promise.all([
50
- client.getDatabase(databaseId),
51
- client.listRows(databaseId),
54
+ client.getDatabase(id),
55
+ client.listRows(id),
52
56
  ]);
53
57
  return jsonResult({ database, rows });
54
58
  } catch (err) {
@@ -66,13 +70,13 @@ export function registerDatabaseTools(server) {
66
70
  'Use rootr_read_database instead when you also need the property/column definitions.',
67
71
  annotations: { readOnlyHint: true, destructiveHint: false },
68
72
  inputSchema: {
69
- databaseId: z.string().describe('DATABASE node id'),
73
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
70
74
  },
71
75
  },
72
76
  async ({ databaseId }) => {
73
77
  try {
74
78
  const client = makeClient();
75
- const rows = await client.listRows(databaseId);
79
+ const rows = await client.listRows(await resolveNodeId(client, databaseId));
76
80
  return jsonResult({ rows });
77
81
  } catch (err) {
78
82
  return errorResult(err);
@@ -91,7 +95,7 @@ export function registerDatabaseTools(server) {
91
95
  'Call rootr_read_database first to learn the columns. Optionally set `position` to insert at an index.',
92
96
  annotations: { readOnlyHint: false, destructiveHint: false },
93
97
  inputSchema: {
94
- databaseId: z.string().describe('DATABASE node id'),
98
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
95
99
  values: z.record(z.unknown()).describe('{ propertyNameOrId: value } for the new row'),
96
100
  position: z.number().optional().describe('0-based insert index; appended at the end if omitted'),
97
101
  },
@@ -99,9 +103,10 @@ export function registerDatabaseTools(server) {
99
103
  async ({ databaseId, values, position }) => {
100
104
  try {
101
105
  const client = makeClient();
102
- const database = await client.getDatabase(databaseId);
106
+ const id = await resolveNodeId(client, databaseId);
107
+ const database = await client.getDatabase(id);
103
108
  const resolved = resolveValueKeys(database.properties || [], values);
104
- const row = await client.createRow(databaseId, { values: resolved, position });
109
+ const row = await client.createRow(id, { values: resolved, position });
105
110
  return jsonResult(row);
106
111
  } catch (err) {
107
112
  return errorResult(err);
@@ -120,7 +125,7 @@ export function registerDatabaseTools(server) {
120
125
  'Optionally set `position` to reorder the row.',
121
126
  annotations: { readOnlyHint: false, destructiveHint: false },
122
127
  inputSchema: {
123
- databaseId: z.string().describe('DATABASE node id'),
128
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
124
129
  rowId: z.string().describe('Row id (must belong to the database)'),
125
130
  values: z.record(z.unknown()).optional().describe('{ propertyNameOrId: newValue | null } partial patch'),
126
131
  position: z.number().optional().describe('New 0-based position for the row'),
@@ -129,12 +134,13 @@ export function registerDatabaseTools(server) {
129
134
  async ({ databaseId, rowId, values, position }) => {
130
135
  try {
131
136
  const client = makeClient();
137
+ const id = await resolveNodeId(client, databaseId);
132
138
  let resolved;
133
139
  if (values !== undefined) {
134
- const database = await client.getDatabase(databaseId);
140
+ const database = await client.getDatabase(id);
135
141
  resolved = resolveValueKeys(database.properties || [], values);
136
142
  }
137
- const row = await client.updateRow(databaseId, rowId, { values: resolved, position });
143
+ const row = await client.updateRow(id, rowId, { values: resolved, position });
138
144
  return jsonResult(row);
139
145
  } catch (err) {
140
146
  return errorResult(err);
@@ -151,14 +157,14 @@ export function registerDatabaseTools(server) {
151
157
  'Verify the row via rootr_read_database first — do not guess rowIds.',
152
158
  annotations: { readOnlyHint: false, destructiveHint: true },
153
159
  inputSchema: {
154
- databaseId: z.string().describe('DATABASE node id'),
160
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
155
161
  rowId: z.string().describe('Row id to delete (must belong to the database)'),
156
162
  },
157
163
  },
158
164
  async ({ databaseId, rowId }) => {
159
165
  try {
160
166
  const client = makeClient();
161
- await client.deleteRow(databaseId, rowId);
167
+ await client.deleteRow(await resolveNodeId(client, databaseId), rowId);
162
168
  return jsonResult({ deleted: true, rowId });
163
169
  } catch (err) {
164
170
  return errorResult(err);
@@ -31,7 +31,12 @@ export function registerDocumentTools(server) {
31
31
  const p = n.path || '';
32
32
  return p === path || p.startsWith(path.endsWith('/') ? path : path + '/');
33
33
  })
34
- .map((n) => `${n.type || '?'} ${n.path || n.name || n.id}`)
34
+ .map((n) => {
35
+ const label = n.path || n.name || n.id;
36
+ // Keep the id visible: tools that take a node id (databases, sheets,
37
+ // whiteboards, …) are otherwise unreachable from a listing.
38
+ return n.id && label !== n.id ? `${n.type || '?'} ${label} [${n.id}]` : `${n.type || '?'} ${label}`;
39
+ })
35
40
  .sort();
36
41
  return textResult(rows.length ? rows.join('\n') : '(no matching nodes)');
37
42
  } catch (err) {
@@ -189,7 +194,11 @@ export function registerDocumentTools(server) {
189
194
  try {
190
195
  const client = makeClient();
191
196
  const results = await client.search(query);
192
- const rows = results.map((r) => `${r.path || r.name || r.nodeId} — ${r.snippet || ''}`);
197
+ const rows = results.map((r) => {
198
+ const label = r.path || r.name || r.nodeId;
199
+ const id = r.nodeId && label !== r.nodeId ? ` [${r.nodeId}]` : '';
200
+ return `${label}${id} — ${r.snippet || ''}`;
201
+ });
193
202
  return textResult(rows.length ? rows.join('\n') : '(no results)');
194
203
  } catch (err) {
195
204
  return errorResult(err);
@@ -256,7 +265,9 @@ export function registerDocumentTools(server) {
256
265
  'and propose git-style edit suggestions the owner can merge. Publishing is an owner-level action: it needs ' +
257
266
  'MANAGE permission, which workspace-scoped keys (including remote-connector tokens) do not carry — use an ' +
258
267
  'ACCOUNT key (PAT) of the owner, or ask the user to publish from the share dialog. ' +
259
- 'Returns the slug/URL. Check rootr_document_public_status first; unpublish via rootr_set_document_public.',
268
+ 'Safe to call again on an already-published document (republish): the public slug/URL never changes, so ' +
269
+ 'links already shared out stay valid — republishing just creates a new published version from the current draft. ' +
270
+ 'Returns the slug/URL. Check rootr_document_public_status first; unpublish or change settings via rootr_set_document_public.',
260
271
  annotations: { readOnlyHint: false, destructiveHint: false },
261
272
  inputSchema: {
262
273
  target: z.string().describe('Document path (starting with "/") or node id'),
@@ -278,19 +289,58 @@ export function registerDocumentTools(server) {
278
289
  {
279
290
  title: 'Update public settings of a Rootr document',
280
291
  description:
281
- 'Update a published Rootr (루터) document\'s public settings — e.g. {"isPublic": false} to unpublish. ' +
292
+ 'Partially update a published Rootr (루터) document\'s public settings — only the fields you pass are ' +
293
+ 'changed, everything else (including slug) is left as-is. Examples: {"isPublic": false} to unpublish; ' +
294
+ '{"allowCommits": true} to let public readers propose edits; {"skin": "tech-dark"} or ' +
295
+ '{"layout": {"cover": true}} to restyle the public page; {"expiresAt": "2026-12-31T00:00:00Z"} to set an ' +
296
+ 'auto-close date, or {"expiresAt": null} to clear it. ' +
282
297
  'Owner-level action (MANAGE): works with an account key (PAT), not with workspace-scoped/connector tokens.',
283
298
  annotations: { readOnlyHint: false, destructiveHint: false },
284
299
  inputSchema: {
285
300
  target: z.string().describe('Document path (starting with "/") or node id'),
286
301
  isPublic: z.boolean().optional().describe('false to unpublish, true to re-enable'),
302
+ allowCommits: z.boolean().optional().describe('Whether public readers can propose git-style edit suggestions'),
303
+ expiresAt: z
304
+ .union([z.string(), z.null()])
305
+ .optional()
306
+ .describe('ISO date to auto-close the public page, or null to clear an existing expiry'),
307
+ skin: z
308
+ .enum([
309
+ 'classic',
310
+ 'minimal',
311
+ 'magazine',
312
+ 'paper',
313
+ 'tech-dark',
314
+ 'vivid',
315
+ 'manual',
316
+ 'book',
317
+ 'editorial',
318
+ 'bento',
319
+ 'glass',
320
+ 'mesh',
321
+ 'immersive',
322
+ 'brutal',
323
+ 'neumorph',
324
+ 'y2k',
325
+ ])
326
+ .optional()
327
+ .describe('Visual skin for the public page'),
328
+ layout: z
329
+ .object({
330
+ cover: z.boolean().optional().describe('Show a cover banner'),
331
+ toc: z.boolean().optional().describe('Show a table of contents'),
332
+ width: z.enum(['narrow', 'wide']).optional().describe('Body width'),
333
+ paging: z.boolean().optional().describe('Paginate long documents'),
334
+ })
335
+ .optional()
336
+ .describe('Layout options; only the sub-fields you pass are changed'),
287
337
  },
288
338
  },
289
- async ({ target, isPublic }) => {
339
+ async ({ target, isPublic, allowCommits, expiresAt, skin, layout }) => {
290
340
  try {
291
341
  const client = makeClient();
292
342
  const id = requireResolved(await resolveTarget(client, target));
293
- return jsonResult(await client.setDocumentPublic(id, { isPublic }));
343
+ return jsonResult(await client.setDocumentPublic(id, { isPublic, allowCommits, expiresAt, skin, layout }));
294
344
  } catch (err) {
295
345
  return errorResult(err);
296
346
  }