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/mcp-tools/forms.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 |
|
|
22
|
-
'
|
|
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(
|
|
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(
|
|
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
|
},
|
package/lib/mcp-tools/misc.js
CHANGED
|
@@ -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. */
|
|
@@ -95,4 +95,87 @@ export function registerMiscTools(server) {
|
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
);
|
|
98
|
+
|
|
99
|
+
// ── READ-ACK — 읽고 확인해야 하는 문서 ─────────────────────────────────
|
|
100
|
+
// 규정·공지처럼 "읽었다"를 남겨야 하는 것. 표·폴더에 걸면 그 안의 문서 전부에
|
|
101
|
+
// 걸리고, 얼마나 읽었는지 완료율로 본다.
|
|
102
|
+
|
|
103
|
+
server.registerTool(
|
|
104
|
+
'rootr_read_requirement',
|
|
105
|
+
{
|
|
106
|
+
title: 'Read the read-acknowledgement status',
|
|
107
|
+
description:
|
|
108
|
+
'Read the READ-ACK state of a Rootr (루터) node — who must read it, the due date, who has read or ' +
|
|
109
|
+
'acknowledged, and (for a DATABASE or FOLDER) how far through the documents inside each person is. ' +
|
|
110
|
+
'Use it to answer "누가 아직 안 읽었나" for a 규정/공지/교육자료. If the node itself has no requirement ' +
|
|
111
|
+
'but an ancestor does, `ancestorRequirementId` names the node that carries it.',
|
|
112
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
113
|
+
inputSchema: {
|
|
114
|
+
nodeId: z.string().describe(`Node id${NODE_TARGET_HINT}`),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
async ({ nodeId }) => {
|
|
118
|
+
try {
|
|
119
|
+
const client = makeClient();
|
|
120
|
+
return jsonResult(await client.readRequirement(await resolveNodeId(client, nodeId)));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return errorResult(err);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
server.registerTool(
|
|
128
|
+
'rootr_set_read_requirement',
|
|
129
|
+
{
|
|
130
|
+
title: 'Require people to read a document',
|
|
131
|
+
description:
|
|
132
|
+
'Make a Rootr (루터) node REQUIRED READING for the given people. Put it on a DOCUMENT for one notice, ' +
|
|
133
|
+
'or on a DATABASE/FOLDER to require everything inside (완료율 rolls up per person). `dueAt` is an ISO ' +
|
|
134
|
+
'date or null for no deadline; `requireExplicitAck: true` means opening it is not enough — the person ' +
|
|
135
|
+
'must press 확인. Replaces the whole roster, so send the full `userIds` list. NOTE: this records a real ' +
|
|
136
|
+
'person as the setter, so it needs a key that acts as a user (a PAT); a workspace connector key with no ' +
|
|
137
|
+
'user attached is refused.',
|
|
138
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
139
|
+
inputSchema: {
|
|
140
|
+
nodeId: z.string().describe(`Node id${NODE_TARGET_HINT}`),
|
|
141
|
+
userIds: z.array(z.string()).describe('Everyone who must read it (replaces the current roster)'),
|
|
142
|
+
dueAt: z.string().nullable().optional().describe('ISO date, or null for no deadline'),
|
|
143
|
+
requireExplicitAck: z
|
|
144
|
+
.boolean()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe('true = opening is not enough, the person must press 확인'),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
async ({ nodeId, userIds, dueAt, requireExplicitAck }) => {
|
|
150
|
+
try {
|
|
151
|
+
const client = makeClient();
|
|
152
|
+
const id = await resolveNodeId(client, nodeId);
|
|
153
|
+
return jsonResult(await client.setReadRequirement(id, { userIds, dueAt, requireExplicitAck }));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return errorResult(err);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
server.registerTool(
|
|
161
|
+
'rootr_clear_read_requirement',
|
|
162
|
+
{
|
|
163
|
+
title: 'Stop requiring a document to be read',
|
|
164
|
+
description:
|
|
165
|
+
'Remove the READ-ACK requirement from a Rootr (루터) node. The record of who already read it is not ' +
|
|
166
|
+
'shown any more; the node itself is untouched.',
|
|
167
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
168
|
+
inputSchema: {
|
|
169
|
+
nodeId: z.string().describe(`Node id${NODE_TARGET_HINT}`),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
async ({ nodeId }) => {
|
|
173
|
+
try {
|
|
174
|
+
const client = makeClient();
|
|
175
|
+
return jsonResult(await client.clearReadRequirement(await resolveNodeId(client, nodeId)));
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return errorResult(err);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
);
|
|
98
181
|
}
|
|
@@ -268,7 +268,11 @@ export function registerPresentationTools(server) {
|
|
|
268
268
|
try {
|
|
269
269
|
const client = makeClient();
|
|
270
270
|
presentationId = await resolveNodeId(client, presentationId);
|
|
271
|
-
|
|
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
|
+
}
|
package/lib/mcp-tools/signed.js
CHANGED
|
@@ -35,7 +35,21 @@ const APPROVAL_STEP = z.object({
|
|
|
35
35
|
members: z
|
|
36
36
|
.array(
|
|
37
37
|
z.object({
|
|
38
|
-
userId: z
|
|
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.
|
|
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)
|
|
238
|
-
'
|
|
239
|
-
'
|
|
240
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
|
318
|
-
'
|
|
319
|
-
'
|
|
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: '
|
|
443
|
+
title: 'Submit and sign many Rootr documents at once',
|
|
420
444
|
description:
|
|
421
|
-
'
|
|
422
|
-
'rootr_request_signing_code + bulk: true).
|
|
423
|
-
'
|
|
424
|
-
'
|
|
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 목록 (경로도 됩니다)'),
|
|
@@ -152,15 +152,27 @@ export function registerWorkspaceTools(server) {
|
|
|
152
152
|
server.registerTool(
|
|
153
153
|
'rootr_scaffold_plan',
|
|
154
154
|
{
|
|
155
|
-
title: '
|
|
155
|
+
title: 'Step 1 of scaffolding — get the design method, then think and ask',
|
|
156
156
|
description:
|
|
157
|
-
'
|
|
158
|
-
'
|
|
159
|
-
'
|
|
160
|
-
'
|
|
161
|
-
'
|
|
162
|
-
'
|
|
163
|
-
'
|
|
157
|
+
'ALWAYS call this first. There are NO templates and NO domain skeletons behind it — deliberately. It ' +
|
|
158
|
+
'does not classify your intent and it will not hand you a structure; what comes back is the METHOD: ' +
|
|
159
|
+
'`analyzeFirst` (the questions to answer about how this job actually runs before you design anything — ' +
|
|
160
|
+
'the daily and weekly rhythm, what piles up, what moves through stages, what arrives from outside, where ' +
|
|
161
|
+
'money is added up, what causes trouble when it is late, and above all what is UNIQUE to this job), ' +
|
|
162
|
+
'`designRules` (how to turn those answers into structure — nouns that pile up become DATABASEs, "one A ' +
|
|
163
|
+
'has many B" becomes a relation, stages become select + board, dates become calendar, outside input ' +
|
|
164
|
+
'becomes FORM + collectInto, calculation becomes SPREADSHEET, counting becomes rollup), `buildingBlocks` ' +
|
|
165
|
+
'(what the product actually offers), `questionPolicy` (ask the user 3-10 questions — you WRITE them, ' +
|
|
166
|
+
'none are provided), and `loop` (the whole sequence). Design it from scratch every time: a bakery, a ' +
|
|
167
|
+
'dental clinic and an SRE team need different things, and no catalogue of skeletons could hold them. ' +
|
|
168
|
+
'Treat it exactly like a coding request. When someone says "build me this" you ask a few clarifying ' +
|
|
169
|
+
'questions, write the code, compile it, run the tests, leave a README, and report. Same sequence here: ' +
|
|
170
|
+
'analyzeFirst = understanding the requirement · questionPolicy = the questions you ask · designRules = ' +
|
|
171
|
+
'writing the code · rootr_scaffold_review = compiling (fix what it flags, compile again) · ' +
|
|
172
|
+
'rootr_scaffold_apply = building · the how-to-use manual = the README · rootr_test_workspace = running ' +
|
|
173
|
+
'the tests. The user should only ever have to type that one sentence — ask your questions, take their ' +
|
|
174
|
+
'answers, and finish the whole thing in this conversation. If they do not answer or say "just do it", ' +
|
|
175
|
+
'assume the most plausible answers, build it anyway, and record the assumptions in the manual.',
|
|
164
176
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
165
177
|
inputSchema: {
|
|
166
178
|
intent: z.string().describe('Natural-language description of what the workspace/tree should be for'),
|
|
@@ -184,8 +196,10 @@ export function registerWorkspaceTools(server) {
|
|
|
184
196
|
description:
|
|
185
197
|
'Create an entirely new Rootr (루터) workspace pre-populated with the given document tree. ' +
|
|
186
198
|
'REQUIRES an account key (PAT) with the workspaces:create scope — a workspace-scoped key cannot do this. ' +
|
|
187
|
-
'PRECONDITION:
|
|
188
|
-
'
|
|
199
|
+
'PRECONDITION: rootr_scaffold_plan first, ask the user its 3-10 clarifying questions, design the tree from ' +
|
|
200
|
+
'their answers, then run rootr_scaffold_review and fix everything it flags at `error` level. The ' +
|
|
201
|
+
'response carries the same `review` back, so a low score here means you shipped something thin. ' +
|
|
202
|
+
'Build a real tool, not a pile of thin markdown files: mix DATABASE (board/' +
|
|
189
203
|
'timeline views, seed rows), SPREADSHEET (formulas), FORM, ISSUE_TRACKER and rich multi-section DOCUMENTs ' +
|
|
190
204
|
'where they fit, and set an emoji `icon` on folders/databases/key documents. The `tree` shape follows ' +
|
|
191
205
|
'the scaffold schema in llms.txt (max 200 nodes). AFTER creating: if your runtime can receive HTTP ' +
|
|
@@ -209,15 +223,105 @@ export function registerWorkspaceTools(server) {
|
|
|
209
223
|
},
|
|
210
224
|
);
|
|
211
225
|
|
|
226
|
+
server.registerTool(
|
|
227
|
+
'rootr_scaffold_review',
|
|
228
|
+
{
|
|
229
|
+
title: 'Check a designed tree BEFORE building it — the compiler pass',
|
|
230
|
+
description:
|
|
231
|
+
'Step 3 of scaffolding (plan → ask → design → REVIEW → fix → apply). Give it the tree you designed and ' +
|
|
232
|
+
'it returns { score 0-100, counts, findings[], summary } — every finding is a concrete defect with the ' +
|
|
233
|
+
'node path, e.g. a table whose only column is the title (that is a list, not a table), a lifecycle ' +
|
|
234
|
+
'column with no board view, a date column with no calendar, a form with nowhere to collect answers, ' +
|
|
235
|
+
'tables that are never related to each other, an empty guide document. Fix what it flags and review ' +
|
|
236
|
+
'again — like re-running a compiler — until nothing at `error` level is left. The rules are ' +
|
|
237
|
+
'domain-agnostic, so this works for a bakery or a dental clinic exactly as well as for a SaaS team. ' +
|
|
238
|
+
'Creates nothing and needs no workspace, so run it as many times as you like before committing.',
|
|
239
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
240
|
+
inputSchema: {
|
|
241
|
+
tree: treeArg('The scaffold tree you designed — an ARRAY of node objects, same shape you would pass to rootr_scaffold_apply'),
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
async ({ tree }) => {
|
|
245
|
+
try {
|
|
246
|
+
const client = makeClient();
|
|
247
|
+
const result = await client.scaffoldReview(parseTree(tree));
|
|
248
|
+
return jsonResult(result);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return errorResult(err);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
server.registerTool(
|
|
256
|
+
'rootr_review_workspace',
|
|
257
|
+
{
|
|
258
|
+
title: 'Check the workspace you ACTUALLY built — not the tree you sent',
|
|
259
|
+
description:
|
|
260
|
+
'Step 5, after building. `rootr_scaffold_review` grades the tree you DESIGNED; this grades what really ' +
|
|
261
|
+
'exists in the workspace right now — whatever tools made it. Run it after every build: a design can score ' +
|
|
262
|
+
'100 and still land broken (a form whose answers go nowhere, a view whose filter vanished, rows that never ' +
|
|
263
|
+
'got linked). Returns the same { score, counts, findings[], summary }. Pass `rootPath` to grade only the ' +
|
|
264
|
+
'subtree you just added, otherwise an older workspace drags the score down and the number stops meaning ' +
|
|
265
|
+
'anything. Fix what it flags with the normal tools (add rows, write the guide document, add the missing ' +
|
|
266
|
+
'columns/views) and run it again until it is clean.',
|
|
267
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
268
|
+
inputSchema: {
|
|
269
|
+
rootPath: z.string().optional().describe('Grade only this subtree, e.g. "/동네 빵집 운영"'),
|
|
270
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
async ({ rootPath, workspace }) => {
|
|
274
|
+
try {
|
|
275
|
+
const ws = requireWorkspace(workspace);
|
|
276
|
+
const client = makeClient(ws);
|
|
277
|
+
return jsonResult(await client.scaffoldReviewBuilt(ws, rootPath));
|
|
278
|
+
} catch (err) {
|
|
279
|
+
return errorResult(err);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
server.registerTool(
|
|
285
|
+
'rootr_test_workspace',
|
|
286
|
+
{
|
|
287
|
+
title: 'RUN the workspace you built — does it actually work?',
|
|
288
|
+
description:
|
|
289
|
+
'The last step, and the one that catches what the two review tools cannot. `rootr_scaffold_review` grades ' +
|
|
290
|
+
'the tree you designed; `rootr_review_workspace` grades the shape of what got built; this one **exercises ' +
|
|
291
|
+
'it**: does every relation point at a row that really exists, do rollups and formulas produce a value on ' +
|
|
292
|
+
'real rows, do board/calendar/filter views reference columns that exist, can the form actually deposit an ' +
|
|
293
|
+
'answer (target table alive AND every question name matching a column — a mismatch makes answers vanish ' +
|
|
294
|
+
'with no error), are sheet formulas computed and free of #REF!/#DIV/0!. Returns { ran, passed, failed, ' +
|
|
295
|
+
'warned, checks[] }. It writes nothing, so run it as often as you like. A workspace can score 100 on both ' +
|
|
296
|
+
'reviews and still fail here — that is exactly the case this exists for. Pass `rootPath` to test only the ' +
|
|
297
|
+
'subtree you just built. Fix every `fail` and run it again.',
|
|
298
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
299
|
+
inputSchema: {
|
|
300
|
+
rootPath: z.string().optional().describe('Test only this subtree, e.g. "/동네 빵집 운영"'),
|
|
301
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
async ({ rootPath, workspace }) => {
|
|
305
|
+
try {
|
|
306
|
+
const ws = requireWorkspace(workspace);
|
|
307
|
+
const client = makeClient(ws);
|
|
308
|
+
return jsonResult(await client.scaffoldSelfTest(ws, rootPath));
|
|
309
|
+
} catch (err) {
|
|
310
|
+
return errorResult(err);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
|
|
212
315
|
server.registerTool(
|
|
213
316
|
'rootr_scaffold_apply',
|
|
214
317
|
{
|
|
215
318
|
title: 'Apply a scaffold tree to an existing workspace',
|
|
216
319
|
description:
|
|
217
320
|
'Create the given document tree inside an existing Rootr (루터) workspace, optionally rooted under an ' +
|
|
218
|
-
'existing path. Works with a workspace key (docs:write scope). PRECONDITION:
|
|
219
|
-
'
|
|
220
|
-
'bar as rootr_create_workspace — real building blocks with icons/views/
|
|
321
|
+
'existing path. Works with a workspace key (docs:write scope). PRECONDITION: rootr_scaffold_plan first, ask ' +
|
|
322
|
+
'its 3-10 clarifying questions, design from the answers, then rootr_scaffold_review and fix every ' +
|
|
323
|
+
'`error` finding (same quality bar as rootr_create_workspace — real building blocks with icons/views/' +
|
|
324
|
+
'seed rows, not thin markdown). The response carries the `review` back so you can see what stayed weak, and AFTER it lands run rootr_review_workspace (rootPath = your new subtree) — that one grades what really exists. ' +
|
|
221
325
|
'AFTER creating: if your runtime can receive HTTP callbacks, offer a webhook on the new subtree ' +
|
|
222
326
|
'(rootr_create_webhook with scopeNodeId) so changes flow back to you — skip your own actorId.',
|
|
223
327
|
annotations: { readOnlyHint: false, destructiveHint: false },
|