rootr-cli 0.10.0 → 0.14.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.
@@ -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
+ }
@@ -7,23 +7,78 @@ const fieldShape = z
7
7
  id: z.string().optional().describe('Stable field id (also the key under response `values`). Server-generated if omitted.'),
8
8
  name: z.string().describe('Field label shown to respondents'),
9
9
  type: z
10
- .enum(['text', 'textarea', 'number', 'select', 'multi_select', 'checkbox', 'date'])
10
+ // FORM-TALLY-P1/P2 — 백엔드 `form-schema.ts`의 FORM_FIELD_TYPES와 **같은 목록**이어야
11
+ // 한다. 여기만 옛 7종에 머물러 있으면 AI가 이메일·파일 항목을 못 만든다.
12
+ .enum([
13
+ 'text', 'textarea', 'number', 'select', 'multi_select', 'checkbox', 'date',
14
+ 'email', 'phone', 'url', 'rating', 'scale', 'time', 'file',
15
+ 'matrix', 'ranking', 'csat', 'nps',
16
+ ])
11
17
  .describe('Field input type'),
12
18
  required: z.boolean().optional().describe('Whether the field must be filled in'),
13
19
  options: z
14
- .array(z.object({ name: z.string(), color: z.string() }))
20
+ // 보기는 글자로만 적어도 된다 — 색은 꾸밈이라 서버가 기본값을 붙인다.
21
+ .array(z.union([z.string(), z.object({ name: z.string(), color: z.string().optional() })]))
15
22
  .optional()
16
- .describe('Choices for select/multi_select fields'),
23
+ .describe('Choices for select/multi_select/matrix/ranking fields'),
24
+ help: z.string().optional().describe('One line under the question, e.g. "예: 010-0000-0000"'),
25
+ placeholder: z.string().optional().describe('Greyed-out example inside the input'),
26
+ min: z.number().optional().describe('rating/scale only: lowest number (rating 1, scale 0 or 1)'),
27
+ max: z.number().optional().describe('rating/scale only: highest number (rating 5, scale 10). 2–10.'),
28
+ rows: z.array(z.string()).optional().describe('matrix only: the rows down the left side'),
29
+ page: z.number().optional().describe('Which page this question is on (1-based). Split a long form.'),
30
+ hidden: z
31
+ .boolean()
32
+ .optional()
33
+ .describe('Not shown; filled from the URL (?이름=값). Use to record where a signup came from.'),
34
+ showIf: z
35
+ .object({ fieldId: z.string(), equals: z.string() })
36
+ .optional()
37
+ .describe('Show this question only when an earlier field equals this value (conditional logic).'),
17
38
  })
18
39
  .describe('One form field definition.');
19
40
 
20
41
  const FIELDS_GUIDE =
21
- 'Fields are typed inputs shown to respondents: text | textarea | number | select | multi_select | checkbox | date; ' +
22
- 'select/multi_select need `options` ({name,color}[]). Read the form first (rootr_read_form) before updating so ' +
42
+ 'Fields are typed inputs shown to respondents. Basic: text | textarea | number | select | multi_select | ' +
43
+ 'checkbox | date. Contact: email | phone | url (each is format-checked server-side). Survey: rating (stars) | ' +
44
+ 'scale (numbered buttons) | csat (1-5) | nps (0-10) | matrix (rows x options, Likert) | ranking (drag order). ' +
45
+ 'Also: time (HH:MM) and file (uploaded on the published page only). ' +
46
+ 'select/multi_select/matrix/ranking need `options` ({name,color}[]); matrix also needs `rows`. ' +
47
+ 'Per-field extras: `help` (a hint line), `placeholder`, `page` (split into multiple pages), `hidden` + URL ' +
48
+ 'params (record the traffic source), `showIf` (show only when an earlier answer matches). ' +
49
+ 'Read the form first (rootr_read_form) before updating so ' +
23
50
  'you keep each field\'s `id` stable — response values are keyed by field id, so renaming/dropping an id orphans ' +
24
51
  'past responses\' answers for that field.';
25
52
 
26
53
  /** FORM node tools: create/read/update the field definitions, list/submit responses, manage public share links. */
54
+ /**
55
+ * FORM-PERM / FORM-MIRROR — 폼 config 에 담기는 것.
56
+ * 자물쇠와 기본값은 코드에는 있는데 도구 설명에 없으면 에이전트가 영영 못 쓴다.
57
+ */
58
+ const FORM_CONFIG_DESC =
59
+ 'Form config object. Known keys: ' +
60
+ '`permissions: { editLock: "managers"|"open", responseScope: "own"|"all" }` — by default only managers may ' +
61
+ 'edit the questions and each person sees only their OWN responses; set responseScope "all" to let writers ' +
62
+ 'read every response (휴가 사유처럼 민감한 것은 기본값 그대로 두세요). ' +
63
+ '`mirror: { fieldMap, defaults: { <표의 칸 이름 또는 id>: value } }` — `defaults` stamps extra values onto the ' +
64
+ 'row a response creates, for columns the person did not answer; use it to start a 신청 at "상신 중" so a ' +
65
+ 'manager has something to move to 승인/거절. What the person actually answered always wins. ' +
66
+ '`publicSubmit` ("anon"|"login") — who may answer from outside the workspace. Publishing the form ' +
67
+ '(`rootr_publish_node`, or the 웹에 공개 switch) turns this on by itself, so you almost never set it by hand; ' +
68
+ 'to stop taking answers use `closesAt`/`maxResponses`, or unpublish. ' +
69
+ // FORM-TALLY-P1/P2 — 여기 안 적으면 에이전트는 이 설정이 있는 줄도 모른다.
70
+ '`intro` / `thankYou` — markdown shown above the form and after submitting (put the date, venue and fee in ' +
71
+ '`intro` instead of cramming them into question labels). ' +
72
+ '`redirectUrl` — send people here after submitting (payment, next step). ' +
73
+ '`notifyEmails: string[]` — email these addresses when a response arrives (up to 10). Without it nobody knows. ' +
74
+ '`confirmEmailField` — the id of an email question; the respondent gets a confirmation mail. ' +
75
+ '`confirmEmailBody` — what that mail says. ' +
76
+ '`closesAt` (ISO) / `maxResponses` — stop accepting after a date or a headcount; `closedMessage` explains why. ' +
77
+ '`webhookUrl` — POST each response to this URL; a Slack incoming-webhook URL works as-is. ' +
78
+ '`theme` (clean|paper|noir|ocean|sunset|mint|bento|brutal|vivid|dark), `cover`, `logo` — how the published ' +
79
+ 'form looks. `cover`/`logo` are image URLs: upload the file with `rootr_upload_attachment` and pass the url ' +
80
+ 'it returns; leave `cover` empty and the form wears the default Rootr cover.'
81
+
27
82
  export function registerFormTools(server) {
28
83
  server.registerTool(
29
84
  'rootr_create_form',
@@ -41,7 +96,7 @@ export function registerFormTools(server) {
41
96
  icon: z.string().optional().describe('Emoji icon for the node'),
42
97
  fields: z.array(fieldShape).optional().describe('Initial field definitions; defaults to an empty form'),
43
98
  targetDatabaseId: z.string().optional().describe(`DATABASE node id${NODE_TARGET_HINT} whose rows mirror this form's responses`),
44
- config: z.record(z.unknown()).optional().describe('Free-form form config object (e.g. publicSubmit mode)'),
99
+ config: z.record(z.unknown()).optional().describe(FORM_CONFIG_DESC),
45
100
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
46
101
  },
47
102
  },
@@ -98,7 +153,7 @@ export function registerFormTools(server) {
98
153
  formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
99
154
  fields: z.array(fieldShape).optional().describe('COMPLETE fields array to set — omitted existing fields will be dropped'),
100
155
  targetDatabaseId: z.string().nullable().optional().describe(`DATABASE node id${NODE_TARGET_HINT} to mirror responses into, or null to unlink`),
101
- config: z.record(z.unknown()).optional().describe('Free-form form config object (e.g. publicSubmit mode)'),
156
+ config: z.record(z.unknown()).optional().describe(FORM_CONFIG_DESC),
102
157
  icon: z.string().optional().describe('Emoji icon for the node'),
103
158
  },
104
159
  },
@@ -148,8 +148,9 @@ export function registerIssueTools(server) {
148
148
  {
149
149
  title: 'Comment on an issue',
150
150
  description:
151
- 'Add a comment to a Rootr (루터) issue within its tracker. Comments carry a user identity, ' +
152
- 'so this requires an ACCOUNT key (PAT) as ROOTR_API_KEY — a workspace key gets a 403.',
151
+ 'Add a comment to a Rootr (루터) issue within its tracker. Comments carry a user identity: ' +
152
+ 'the comment is posted as the person the ROOTR_API_KEY belongs to. Both an account key (PAT) and a ' +
153
+ 'workspace key work, since a workspace key is bound to the member who minted it.',
153
154
  annotations: { readOnlyHint: false, destructiveHint: false },
154
155
  inputSchema: {
155
156
  trackerId: z.string().describe(`Issue tracker node id${NODE_TARGET_HINT}`),
@@ -1,6 +1,6 @@
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
4
  import { resolveTarget, requireResolved } from '../resolve.js';
5
5
 
6
6
  /** Miscellaneous document/node tools: version history, trash. */
@@ -31,6 +31,65 @@ export function registerMiscTools(server) {
31
31
  },
32
32
  );
33
33
 
34
+ /**
35
+ * DOC-RESTORE — **되돌리기.** 목록만 있고 되돌리는 문이 없어서, AI가 잘못
36
+ * 덮어써 내용이 날아가도 아무도 복구할 수 없었다(2026-08-31 에이랩스 피드백:
37
+ * "AI에게 이전 버전으로 돌려달라고 했지만 불가능"). 버전은 서버에 이미 쌓여
38
+ * 있었고, 부를 방법만 없었다.
39
+ */
40
+ server.registerTool(
41
+ 'rootr_read_document_version',
42
+ {
43
+ title: 'Read one saved version of a document',
44
+ description:
45
+ 'Read the full saved body of ONE past version of a Rootr (루터) document. Use this before ' +
46
+ 'rootr_restore_document_version to confirm the version actually holds the content the user wants back — ' +
47
+ 'restoring is a write, so look first. Get version ids from rootr_document_versions.',
48
+ annotations: { readOnlyHint: true, destructiveHint: false },
49
+ inputSchema: {
50
+ target: z.string().describe('Document path (starting with "/") or node id'),
51
+ versionId: z.string().describe('Version id from rootr_document_versions'),
52
+ },
53
+ },
54
+ async ({ target, versionId }) => {
55
+ try {
56
+ const client = makeClient();
57
+ const id = requireResolved(await resolveTarget(client, target));
58
+ return jsonResult(await client.getDocumentVersion(id, versionId));
59
+ } catch (err) {
60
+ return errorResult(err);
61
+ }
62
+ },
63
+ );
64
+
65
+ server.registerTool(
66
+ 'rootr_restore_document_version',
67
+ {
68
+ title: 'Restore a document to an earlier version',
69
+ description:
70
+ 'Roll a Rootr (루터) document back to one of its saved versions — the recovery path when an edit ' +
71
+ '(yours or anyone else\'s) destroyed content. WORKFLOW: rootr_document_versions to list, ' +
72
+ 'rootr_read_document_version to confirm the right one, then restore. The current body is itself saved ' +
73
+ 'as a new version first, so restoring is reversible — but it DOES overwrite what is there now, so ' +
74
+ 'never guess at the version id. If the user says "undo what you just did" or "bring back the old ' +
75
+ 'content", this is the tool.',
76
+ annotations: { readOnlyHint: false, destructiveHint: true },
77
+ inputSchema: {
78
+ target: z.string().describe('Document path (starting with "/") or node id'),
79
+ versionId: z.string().describe('Version id to restore, from rootr_document_versions'),
80
+ },
81
+ },
82
+ async ({ target, versionId }) => {
83
+ try {
84
+ const client = makeClient();
85
+ const id = requireResolved(await resolveTarget(client, target));
86
+ return jsonResult(await client.restoreDocumentVersion(id, versionId));
87
+ } catch (err) {
88
+ return errorResult(err);
89
+ }
90
+ },
91
+ );
92
+
34
93
  server.registerTool(
35
94
  'rootr_update_node',
36
95
  {
@@ -95,4 +154,87 @@ export function registerMiscTools(server) {
95
154
  }
96
155
  },
97
156
  );
157
+
158
+ // ── READ-ACK — 읽고 확인해야 하는 문서 ─────────────────────────────────
159
+ // 규정·공지처럼 "읽었다"를 남겨야 하는 것. 표·폴더에 걸면 그 안의 문서 전부에
160
+ // 걸리고, 얼마나 읽었는지 완료율로 본다.
161
+
162
+ server.registerTool(
163
+ 'rootr_read_requirement',
164
+ {
165
+ title: 'Read the read-acknowledgement status',
166
+ description:
167
+ 'Read the READ-ACK state of a Rootr (루터) node — who must read it, the due date, who has read or ' +
168
+ 'acknowledged, and (for a DATABASE or FOLDER) how far through the documents inside each person is. ' +
169
+ 'Use it to answer "누가 아직 안 읽었나" for a 규정/공지/교육자료. If the node itself has no requirement ' +
170
+ 'but an ancestor does, `ancestorRequirementId` names the node that carries it.',
171
+ annotations: { readOnlyHint: true, destructiveHint: false },
172
+ inputSchema: {
173
+ nodeId: z.string().describe(`Node id${NODE_TARGET_HINT}`),
174
+ },
175
+ },
176
+ async ({ nodeId }) => {
177
+ try {
178
+ const client = makeClient();
179
+ return jsonResult(await client.readRequirement(await resolveNodeId(client, nodeId)));
180
+ } catch (err) {
181
+ return errorResult(err);
182
+ }
183
+ },
184
+ );
185
+
186
+ server.registerTool(
187
+ 'rootr_set_read_requirement',
188
+ {
189
+ title: 'Require people to read a document',
190
+ description:
191
+ 'Make a Rootr (루터) node REQUIRED READING for the given people. Put it on a DOCUMENT for one notice, ' +
192
+ 'or on a DATABASE/FOLDER to require everything inside (완료율 rolls up per person). `dueAt` is an ISO ' +
193
+ 'date or null for no deadline; `requireExplicitAck: true` means opening it is not enough — the person ' +
194
+ 'must press 확인. Replaces the whole roster, so send the full `userIds` list. NOTE: this records a real ' +
195
+ 'person as the setter, so it needs a key that acts as a user (a PAT); a workspace connector key with no ' +
196
+ 'user attached is refused.',
197
+ annotations: { readOnlyHint: false, destructiveHint: false },
198
+ inputSchema: {
199
+ nodeId: z.string().describe(`Node id${NODE_TARGET_HINT}`),
200
+ userIds: z.array(z.string()).describe('Everyone who must read it (replaces the current roster)'),
201
+ dueAt: z.string().nullable().optional().describe('ISO date, or null for no deadline'),
202
+ requireExplicitAck: z
203
+ .boolean()
204
+ .optional()
205
+ .describe('true = opening is not enough, the person must press 확인'),
206
+ },
207
+ },
208
+ async ({ nodeId, userIds, dueAt, requireExplicitAck }) => {
209
+ try {
210
+ const client = makeClient();
211
+ const id = await resolveNodeId(client, nodeId);
212
+ return jsonResult(await client.setReadRequirement(id, { userIds, dueAt, requireExplicitAck }));
213
+ } catch (err) {
214
+ return errorResult(err);
215
+ }
216
+ },
217
+ );
218
+
219
+ server.registerTool(
220
+ 'rootr_clear_read_requirement',
221
+ {
222
+ title: 'Stop requiring a document to be read',
223
+ description:
224
+ 'Remove the READ-ACK requirement from a Rootr (루터) node. The record of who already read it is not ' +
225
+ 'shown any more; the node itself is untouched.',
226
+ annotations: { readOnlyHint: false, destructiveHint: true },
227
+ inputSchema: {
228
+ nodeId: z.string().describe(`Node id${NODE_TARGET_HINT}`),
229
+ },
230
+ },
231
+ async ({ nodeId }) => {
232
+ try {
233
+ const client = makeClient();
234
+ return jsonResult(await client.clearReadRequirement(await resolveNodeId(client, nodeId)));
235
+ } catch (err) {
236
+ return errorResult(err);
237
+ }
238
+ },
239
+ );
98
240
  }
@@ -268,7 +268,11 @@ export function registerPresentationTools(server) {
268
268
  try {
269
269
  const client = makeClient();
270
270
  presentationId = await resolveNodeId(client, presentationId);
271
- const result = await client.updatePresentationSlide(presentationId, slideId, slide);
271
+ // 블록 id 는 이 도구도 채워 준다 — 스키마가 "생략하면 자동 생성"이라고
272
+ // 적어 두고서 이 길로만 400(blocks[].id is required)을 내던 것을 고친다
273
+ // (2026-08-29 실측). 슬라이드 id 는 서버가 어차피 slideId 로 못 박는다.
274
+ const patched = ensureSlideIds([{ ...slide, id: slideId }])[0];
275
+ const result = await client.updatePresentationSlide(presentationId, slideId, patched);
272
276
  return jsonResult(result);
273
277
  } catch (err) {
274
278
  return errorResult(err);
@@ -0,0 +1,148 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, errorResult, makeClient, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
4
+
5
+ /**
6
+ * PUBLIC-DB-DESIGN — 공개한 표를 **표 말고 다른 꼴로도** 보여준다
7
+ * (창업자 지시 2026-08-28: "꼭 표 형태일 필욘 없잖아").
8
+ * 값 목록은 서버(`public-nodes.dto.ts`)와 **같아야** 한다.
9
+ */
10
+ const DB_DESIGN_SCHEMA = z.object({
11
+ layout: z
12
+ .enum(['table', 'cards', 'gallery', 'list', 'board', 'timeline', 'calendar', 'chart', 'ranking'])
13
+ .optional(),
14
+ skin: z.enum(['clean', 'paper', 'dark', 'vivid', 'bento', 'brutal']).optional(),
15
+ });
16
+
17
+ /**
18
+ * PUBLIC-ANY — **어떤 타입이든 공개 발행한다**(창업자 지시 2026-08-27).
19
+ *
20
+ * 문서·덱만 되던 것을 표·시트·화이트보드·폼·이슈·로그·CRM·녹음·서명문서까지 열었다.
21
+ * 문서는 이 도구로 불러도 예전 발행 경로로 넘어가므로, **무엇을 공개하든 여기 하나만
22
+ * 쓰면 된다** — 타입마다 다른 도구를 찾을 필요가 없다.
23
+ */
24
+ export function registerPublicTools(server) {
25
+ server.registerTool(
26
+ 'rootr_publish_node',
27
+ {
28
+ title: 'Publish anything to a public URL',
29
+ description:
30
+ 'Publish ANY Rootr (루터) node at a public read-only URL (rootr.io/p/{slug}) — a DATABASE (visitors get a ' +
31
+ 'searchable table and can clone it into their own workspace), SPREADSHEET, WHITEBOARD, FORM (questions only), ' +
32
+ 'ISSUE_TRACKER, LOG, CRM, RECORDING (transcript only), SIGNED or DOCUMENT. Use this instead of ' +
33
+ 'rootr_publish_document unless you specifically want the document-only extras (skins, edit proposals). ' +
34
+ 'FOLDER cannot be published. ' +
35
+ 'Set `includeChildren: true` to publish everything underneath as well — the WHOLE subtree, grandchildren ' +
36
+ 'included, each getting its own URL and listed on the parent page. Leave it off and only this one node goes ' +
37
+ 'out (its sub-page names do not even appear). Turning it back off, closing the parent, moving a child out, ' +
38
+ 'or deleting the parent all close the pages that went out because of it — pages the owner published on their ' +
39
+ 'own are left alone. ' +
40
+ 'What is deliberately NOT exposed: a table\'s person/attachment/relation columns, the documents attached to ' +
41
+ 'its rows, form responses, recording audio, CRM contacts and deal amounts, and signer names on a signed ' +
42
+ 'document. Rows/entries are capped at 500 per page and the page says so when it truncates. ' +
43
+ 'Owner-level action (MANAGE): use an ACCOUNT key (PAT), not a workspace-scoped/connector token. ' +
44
+ 'Safe to call again — the slug never changes, so links already shared stay valid.',
45
+ annotations: { readOnlyHint: false, destructiveHint: false },
46
+ inputSchema: {
47
+ target: z.string().describe(`Node id${NODE_TARGET_HINT}`),
48
+ includeChildren: z
49
+ .boolean()
50
+ .optional()
51
+ .describe('Also publish everything underneath (whole subtree). Default false.'),
52
+ view: z
53
+ .string()
54
+ .optional()
55
+ .describe('DATABASE only: which saved view the public page opens with (view id).'),
56
+ design: DB_DESIGN_SCHEMA.optional().describe(
57
+ 'DATABASE only: how the public page presents the table. `layout` is one of ' +
58
+ 'table, cards, gallery, list, board, timeline, calendar, chart, ranking; `skin` is one of ' +
59
+ 'clean, paper, dark, vivid, bento, brutal. Only the keys you send change — setting a skin ' +
60
+ 'leaves the layout alone. Layouts that need a column (calendar/timeline need a date, board a ' +
61
+ 'select/status, ranking a number, chart either) fall back to the table and say why.',
62
+ ),
63
+ },
64
+ },
65
+ async ({ target, includeChildren, view, design }) => {
66
+ try {
67
+ const client = makeClient();
68
+ const id = await resolveNodeId(client, target);
69
+ return jsonResult(await client.publishNode(id, { includeChildren, view, design }));
70
+ } catch (err) {
71
+ return errorResult(err);
72
+ }
73
+ },
74
+ );
75
+
76
+ server.registerTool(
77
+ 'rootr_set_node_public',
78
+ {
79
+ title: 'Update public settings of anything published',
80
+ description:
81
+ 'Partially update a published Rootr (루터) node\'s public settings — only the fields you pass change, and ' +
82
+ 'the slug is never touched, so shared links stay valid. ' +
83
+ '`{"isPublic": false}` closes it (and closes whatever went out under it); ' +
84
+ '`{"includeChildren": false}` closes just the sub-pages; ' +
85
+ '`{"allowClone": false}` hides the "clone this" button AND refuses the clone API; ' +
86
+ '`{"password": "..."}` locks the page (the content is not sent at all until the password is entered — it is ' +
87
+ 'not hidden in the browser), `{"password": null}` unlocks; ' +
88
+ '`{"expiresAt": "2026-12-31T00:00:00Z"}` auto-closes on that date, `null` clears it; ' +
89
+ '`{"noIndex": true}` asks search engines to stay away; ' +
90
+ '`{"design": {"layout": "board", "skin": "paper"}}` changes how a published TABLE looks — it does not ' +
91
+ 'have to be a table (cards, gallery, list, board, timeline, calendar, chart, ranking). ' +
92
+ 'For DOCUMENT nodes use rootr_set_document_public instead — documents keep their own settings (skins, ' +
93
+ 'edit proposals) on the older path. Owner-level action (MANAGE): use an account key (PAT).',
94
+ annotations: { readOnlyHint: false, destructiveHint: false },
95
+ inputSchema: {
96
+ target: z.string().describe(`Node id${NODE_TARGET_HINT}`),
97
+ isPublic: z.boolean().optional(),
98
+ includeChildren: z.boolean().optional(),
99
+ allowClone: z.boolean().optional(),
100
+ noIndex: z.boolean().optional(),
101
+ expiresAt: z.string().nullable().optional().describe('ISO date, or null to clear'),
102
+ password: z.string().nullable().optional().describe('null or "" removes the lock'),
103
+ view: z.string().optional().describe('DATABASE only: view id the public page opens with'),
104
+ design: DB_DESIGN_SCHEMA.optional().describe(
105
+ 'DATABASE only: how the public page presents the table. `layout` — table, cards, gallery, list, ' +
106
+ 'board, timeline, calendar, chart, ranking. `skin` — clean, paper, dark, vivid, bento, brutal. ' +
107
+ 'Only the keys you send change.',
108
+ ),
109
+ },
110
+ },
111
+ async ({ target, ...settings }) => {
112
+ try {
113
+ const client = makeClient();
114
+ const id = await resolveNodeId(client, target);
115
+ const body = Object.fromEntries(Object.entries(settings).filter(([, v]) => v !== undefined));
116
+ return jsonResult(await client.setNodePublic(id, body));
117
+ } catch (err) {
118
+ return errorResult(err);
119
+ }
120
+ },
121
+ );
122
+
123
+ server.registerTool(
124
+ 'rootr_node_public_status',
125
+ {
126
+ title: 'Is this published, and at what URL',
127
+ description:
128
+ 'Check whether a Rootr (루터) node is published and how: its public slug (rootr.io/p/{slug}), whether it is ' +
129
+ 'currently open, when it expires, whether a password is set (the password itself is never returned), whether ' +
130
+ 'cloning is allowed, and whether the pages underneath went out with it. ' +
131
+ '`publishable: false` means this type cannot be published at all (FOLDER). ' +
132
+ 'Call this before publishing so you do not tell the user a page is new when it already had a URL.',
133
+ annotations: { readOnlyHint: true, destructiveHint: false },
134
+ inputSchema: {
135
+ target: z.string().describe(`Node id${NODE_TARGET_HINT}`),
136
+ },
137
+ },
138
+ async ({ target }) => {
139
+ try {
140
+ const client = makeClient();
141
+ const id = await resolveNodeId(client, target);
142
+ return jsonResult(await client.nodePublicStatus(id));
143
+ } catch (err) {
144
+ return errorResult(err);
145
+ }
146
+ },
147
+ );
148
+ }
@@ -35,7 +35,21 @@ const APPROVAL_STEP = z.object({
35
35
  members: z
36
36
  .array(
37
37
  z.object({
38
- userId: z.string().describe('워크스페이스 멤버의 userId (외부인은 아직 결재자가 될 수 없다)'),
38
+ userId: z
39
+ .string()
40
+ .optional()
41
+ .describe('워크스페이스 멤버의 userId. 밖의 사람이면 대신 email을 쓴다'),
42
+ email: z
43
+ .string()
44
+ .optional()
45
+ .describe(
46
+ '워크스페이스 밖 결재자의 메일 주소. 그 사람에게 서명 링크가 메일로 가고, ' +
47
+ '계정 없이 서명한다. userId와 둘 중 하나만 쓴다',
48
+ ),
49
+ displayName: z
50
+ .string()
51
+ .optional()
52
+ .describe('메일 결재자를 화면에 뭐라고 부를지. 생략하면 메일 주소를 그대로 쓴다'),
39
53
  role: z
40
54
  .enum(['AUTHOR', 'REVIEWER', 'APPROVER'])
41
55
  .optional()
@@ -142,7 +156,8 @@ export function registerSignedDocTools(server) {
142
156
  'Replace the approval line (결재라인) of a SIGNED document. The array order IS the signing order; ' +
143
157
  'each step can require everyone (ALL), anyone (ANY) or N of M. Only possible BEFORE submit — once ' +
144
158
  'the document is submitted the line is frozen with the body (409 SIGNED_DOC_LOCKED); reject it first ' +
145
- 'to reopen. All members must be workspace members.',
159
+ 'to reopen. Each member is EITHER a workspace member (userId) OR an outside person (email) — ' +
160
+ 'an email signer gets a signing link by mail and signs without an account. Never both on one member.',
146
161
  annotations: { readOnlyHint: false, destructiveHint: true },
147
162
  inputSchema: {
148
163
  nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
@@ -234,21 +249,29 @@ export function registerSignedDocTools(server) {
234
249
  {
235
250
  title: 'Submit (freeze + first-sign) a signed document',
236
251
  description:
237
- 'Submit a SIGNED document: the body and attachments FREEZE (their SHA-256 is recorded), the author ' +
238
- 'signs first, and the next step\'s signers are notified. IRREVERSIBLE except by rejection — after ' +
239
- 'this the only way to change the content is a revision (rootr_revise_signed_document). Needs the ' +
240
- 'emailed code from rootr_request_signing_code.',
252
+ 'Submit a SIGNED document: the body and attachments FREEZE (their SHA-256 is recorded) and the ' +
253
+ 'first step\'s signers are notified. IRREVERSIBLE except by rejection — after this the only way ' +
254
+ 'to change the content is a revision (rootr_revise_signed_document). ' +
255
+ 'Submitting and signing are DIFFERENT acts: submitting only needs write access. `code` is required ' +
256
+ 'ONLY when the submitter is themselves a signer in step 1, because then their signature is stamped ' +
257
+ 'at the same time — get it with rootr_request_signing_code and ask the human for it. If step 1 has ' +
258
+ 'no account members (e.g. only email signers), omit `code` entirely. When in doubt omit it: the ' +
259
+ 'server answers REAUTH_REQUIRED if a code was in fact needed.',
241
260
  annotations: { readOnlyHint: false, destructiveHint: true },
242
261
  inputSchema: {
243
262
  nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
244
- code: z.string().describe(CODE_HINT),
263
+ code: z
264
+ .string()
265
+ .optional()
266
+ .describe(`올리는 사람이 1단계 서명자일 때만 필요합니다. ${CODE_HINT}`),
245
267
  workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
246
268
  },
247
269
  },
248
270
  async ({ nodeId, code, workspace }) => {
249
271
  try {
250
272
  const client = makeClient(workspace);
251
- return jsonResult(await client.submitSignedDoc(await resolveNodeId(client, nodeId), { code }));
273
+ const id = await resolveNodeId(client, nodeId);
274
+ return jsonResult(await client.submitSignedDoc(id, code ? { code } : {}));
252
275
  } catch (err) {
253
276
  return errorResult(err);
254
277
  }
@@ -314,9 +337,10 @@ export function registerSignedDocTools(server) {
314
337
  {
315
338
  title: 'Reopen an unsigned signed-document draft',
316
339
  description:
317
- 'Unlock a SIGNED document that was submitted but that NOBODY has signed yet, so the body and the ' +
318
- 'approval line can be edited again. Once any signature exists this refuses — make a revision instead ' +
319
- '(rootr_revise_signed_document).',
340
+ 'Unlock a SIGNED document so the body and the approval line can be edited again. Works when the ' +
341
+ 'document was sent back (REJECTED), or when it is still in progress and **nobody has signed yet** — ' +
342
+ 'the escape hatch for "I submitted it to the wrong address". Once any signature exists this refuses: ' +
343
+ 'make a revision instead (rootr_revise_signed_document).',
320
344
  annotations: { readOnlyHint: false, destructiveHint: true },
321
345
  inputSchema: {
322
346
  nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
@@ -416,12 +440,16 @@ export function registerSignedDocTools(server) {
416
440
  server.registerTool(
417
441
  'rootr_bulk_sign_documents',
418
442
  {
419
- title: 'Sign many Rootr documents at once',
443
+ title: 'Submit and sign many Rootr documents at once',
420
444
  description:
421
- 'Sign up to 100 SIGNED documents in one call with ONE emailed code (get it with ' +
422
- 'rootr_request_signing_code + bulk: true). Documents where it is not your turn are skipped and ' +
423
- 'reported, not failed. This is what makes 연구노트 usable in practice — a 연구책임자 signing "이번 주 ' +
424
- '미점검 12건" without opening each one.',
445
+ 'Process up to 100 SIGNED documents in one call with ONE emailed code (get it with ' +
446
+ 'rootr_request_signing_code + bulk: true). ' +
447
+ '**DRAFT documents are submitted too** — you do NOT need a separate per-document code to submit ' +
448
+ 'them first. A draft is frozen and, if you are a step-1 signer, stamped at the same time; a document ' +
449
+ 'already IN_PROGRESS is simply signed. Each result carries `did`: "submitted", "submitted+signed" ' +
450
+ 'or "signed". Documents where it is not your turn are skipped and reported, not failed. ' +
451
+ 'This is what makes 연구노트 usable in practice — a 연구책임자 clearing "이번 주 미점검 12건" ' +
452
+ 'without opening each one. Submitting FREEZES the body irreversibly, so pass only the ids you mean.',
425
453
  annotations: { readOnlyHint: false, destructiveHint: true },
426
454
  inputSchema: {
427
455
  nodeIds: z.array(z.string()).min(1).max(100).describe('서명할 노드 id 목록 (경로도 됩니다)'),