rootr-cli 0.8.1 → 0.10.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 +106 -1
- package/bin/rootr.js +532 -2
- package/lib/client.js +271 -7
- package/lib/mcp-tools/databases.js +421 -18
- package/lib/mcp-tools/documents.js +230 -3
- package/lib/mcp-tools/forms.js +87 -2
- package/lib/mcp-tools/misc.js +40 -0
- package/lib/mcp-tools/signed.js +467 -0
- package/lib/mcp-tools/workspaces.js +8 -1
- package/lib/mcp.js +13 -1
- package/package.json +1 -1
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
jsonResult,
|
|
5
|
+
textResult,
|
|
6
|
+
errorResult,
|
|
7
|
+
makeClient,
|
|
8
|
+
requireWorkspace,
|
|
9
|
+
resolveNodeId,
|
|
10
|
+
resolveOptionalNodeId,
|
|
11
|
+
NODE_TARGET_HINT,
|
|
12
|
+
} from './shared.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* SIGN-001 — 서명문서 · 연구노트.
|
|
16
|
+
*
|
|
17
|
+
* 마크다운 문서인데 **올리는 순간 본문이 얼고**, 결재라인에 적힌 사람들이 차례로
|
|
18
|
+
* 서명하면 서명마다 시점확인(RFC 3161 TSA)과 워크스페이스 원장 해시 체이닝이
|
|
19
|
+
* 붙는다. 국가R&D 전자연구노트 지침이 요구하는 ①전자서명 ②시점확인 ③위변조확인이
|
|
20
|
+
* 그대로 이 셋이다.
|
|
21
|
+
*
|
|
22
|
+
* 에이전트가 대신 눌러 줄 수 없는 자리가 하나 있다: **서명**. 확인 코드가 사람
|
|
23
|
+
* 메일로만 가고, 그게 "본인이 눌렀다"의 유일한 증거라서 그렇다. 그래서 흐름은
|
|
24
|
+
* 항상 rootr_request_signing_code → (사람이 메일에서 코드를 읽어 알려 준다) →
|
|
25
|
+
* rootr_sign_document 다. 코드를 지어내지 말고 사람에게 물어야 한다.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const APPROVAL_STEP = z.object({
|
|
29
|
+
label: z.string().optional().describe('단계 이름, 예: "기재자" / "점검자" / "부서장"'),
|
|
30
|
+
rule: z
|
|
31
|
+
.enum(['ALL', 'ANY', 'N_OF_M'])
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('ALL=이 단계 전원 서명(기본), ANY=아무나 한 명, N_OF_M=requiredCount 명'),
|
|
34
|
+
requiredCount: z.number().int().min(1).optional().describe('rule이 N_OF_M일 때 필요한 인원 수'),
|
|
35
|
+
members: z
|
|
36
|
+
.array(
|
|
37
|
+
z.object({
|
|
38
|
+
userId: z.string().describe('워크스페이스 멤버의 userId (외부인은 아직 결재자가 될 수 없다)'),
|
|
39
|
+
role: z
|
|
40
|
+
.enum(['AUTHOR', 'REVIEWER', 'APPROVER'])
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('AUTHOR=기재자, REVIEWER=점검자, APPROVER=결재자. 생략하면 자리로 정해진다'),
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
.min(1),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const CODE_HINT =
|
|
49
|
+
'메일로 받은 6자리 확인 코드. 먼저 rootr_request_signing_code 를 부르면 사용자 메일로 가고, ' +
|
|
50
|
+
'사용자가 알려 준 값을 그대로 넣는다. 절대 지어내지 말 것 — 틀리면 403이고 5회 틀리면 코드가 죽는다.';
|
|
51
|
+
|
|
52
|
+
export function registerSignedDocTools(server) {
|
|
53
|
+
server.registerTool(
|
|
54
|
+
'rootr_create_signed_document',
|
|
55
|
+
{
|
|
56
|
+
title: 'Create a Rootr signed document',
|
|
57
|
+
description:
|
|
58
|
+
'Create a SIGNED document (서명문서 · 전자결재 · 국가R&D 전자연구노트) in a Rootr (루터) workspace. ' +
|
|
59
|
+
'A markdown document that FREEZES on submit: after that the body, attachments and approval line ' +
|
|
60
|
+
'cannot be edited, every signature carries an RFC 3161 timestamp, and the whole workspace keeps a ' +
|
|
61
|
+
'hash-chained ledger so tampering is detectable. Signed documents cannot be deleted (30-year ' +
|
|
62
|
+
'retention). Use this instead of a plain DOCUMENT whenever the content must be provable — 결재/승인 ' +
|
|
63
|
+
'문서, 연구노트, 점검 기록, 회의 확인서. ' +
|
|
64
|
+
'Flow: create (body + approval line) → edit the body freely with rootr_write/rootr_append → ' +
|
|
65
|
+
'rootr_request_signing_code → ask the human for the 6-digit code → rootr_submit_signed_document ' +
|
|
66
|
+
'(freezes + first signature) → each remaining signer does request-code + rootr_sign_document.',
|
|
67
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
68
|
+
inputSchema: {
|
|
69
|
+
name: z.string().optional().describe('문서 이름, 예: "2026-08-25 실험 기록"'),
|
|
70
|
+
path: z
|
|
71
|
+
.string()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe('트리 경로로 만들 때, 예: "/연구노트/2026-08-25 실험기록" (parentId 대신)'),
|
|
74
|
+
parentId: z.string().optional().describe(`부모 폴더 id${NODE_TARGET_HINT}`),
|
|
75
|
+
createParents: z.boolean().optional().describe('path를 쓸 때 없는 상위 폴더까지 만든다'),
|
|
76
|
+
icon: z.string().optional().describe('이모지 아이콘'),
|
|
77
|
+
content: z.string().optional().describe('본문 마크다운. 올리기 전까지는 얼마든지 고칠 수 있다'),
|
|
78
|
+
mode: z
|
|
79
|
+
.enum(['APPROVAL', 'RESEARCH_NOTE'])
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('APPROVAL=전자결재(기본), RESEARCH_NOTE=연구노트'),
|
|
82
|
+
reviewerUserId: z
|
|
83
|
+
.string()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe(
|
|
86
|
+
'연구노트 프리셋용 점검자(연구책임자) userId. approvalLine 없이 이것만 주면 ' +
|
|
87
|
+
'"기재자(만든 사람) → 점검자" 두 단계가 자동으로 짜인다',
|
|
88
|
+
),
|
|
89
|
+
noteMeta: z
|
|
90
|
+
.record(z.any())
|
|
91
|
+
.optional()
|
|
92
|
+
.describe('연구노트 표지 정보: 과제번호·과제명·연구책임자·기관 등'),
|
|
93
|
+
approvalLine: z
|
|
94
|
+
.array(APPROVAL_STEP)
|
|
95
|
+
.optional()
|
|
96
|
+
.describe('결재라인. 배열 순서가 곧 서명 순서다'),
|
|
97
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
async ({ parentId, workspace, ...rest }) => {
|
|
101
|
+
try {
|
|
102
|
+
const ws = requireWorkspace(workspace);
|
|
103
|
+
const client = makeClient(ws);
|
|
104
|
+
parentId = await resolveOptionalNodeId(client, parentId);
|
|
105
|
+
return jsonResult(await client.createSignedDoc(ws, { ...rest, parentId }));
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return errorResult(err);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
server.registerTool(
|
|
113
|
+
'rootr_read_signed_document',
|
|
114
|
+
{
|
|
115
|
+
title: 'Read a Rootr signed document state',
|
|
116
|
+
description:
|
|
117
|
+
'Read a SIGNED document\'s signing state: mode, status (DRAFT/IN_PROGRESS/COMPLETED/REJECTED/' +
|
|
118
|
+
'SUPERSEDED), whether it is locked, the approval line with each step\'s progress, every signature so far ' +
|
|
119
|
+
'(signer, time, TSA timestamp), whether the body still matches the frozen hash, and whether YOU can ' +
|
|
120
|
+
'sign right now (canSign). Read the body itself with rootr_read.',
|
|
121
|
+
annotations: { readOnlyHint: true },
|
|
122
|
+
inputSchema: {
|
|
123
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
124
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
async ({ nodeId, workspace }) => {
|
|
128
|
+
try {
|
|
129
|
+
const client = makeClient(workspace);
|
|
130
|
+
return jsonResult(await client.getSignedDoc(await resolveNodeId(client, nodeId)));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return errorResult(err);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
server.registerTool(
|
|
138
|
+
'rootr_set_approval_line',
|
|
139
|
+
{
|
|
140
|
+
title: 'Set a signed document approval line',
|
|
141
|
+
description:
|
|
142
|
+
'Replace the approval line (결재라인) of a SIGNED document. The array order IS the signing order; ' +
|
|
143
|
+
'each step can require everyone (ALL), anyone (ANY) or N of M. Only possible BEFORE submit — once ' +
|
|
144
|
+
'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.',
|
|
146
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
147
|
+
inputSchema: {
|
|
148
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
149
|
+
steps: z.array(APPROVAL_STEP).min(1).describe('단계 배열. 순서가 곧 서명 순서다'),
|
|
150
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
async ({ nodeId, steps, workspace }) => {
|
|
154
|
+
try {
|
|
155
|
+
const client = makeClient(workspace);
|
|
156
|
+
return jsonResult(await client.setApprovalLine(await resolveNodeId(client, nodeId), steps));
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return errorResult(err);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
server.registerTool(
|
|
164
|
+
'rootr_apply_research_note_preset',
|
|
165
|
+
{
|
|
166
|
+
title: 'Turn a signed document into a research note',
|
|
167
|
+
description:
|
|
168
|
+
'Switch a SIGNED document to RESEARCH_NOTE mode and build the two-step line the Korean national R&D ' +
|
|
169
|
+
'guideline requires: 기재자(the caller) → 점검자(the reviewer/연구책임자). Optionally stores the cover ' +
|
|
170
|
+
'metadata (과제번호·과제명·기관). Use this instead of hand-building the approval line for 연구노트.',
|
|
171
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
172
|
+
inputSchema: {
|
|
173
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
174
|
+
reviewerUserId: z.string().describe('점검자(연구책임자)의 userId'),
|
|
175
|
+
noteMeta: z.record(z.any()).optional().describe('표지 정보: 과제번호·과제명·연구책임자·기관 등'),
|
|
176
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
async ({ nodeId, reviewerUserId, noteMeta, workspace }) => {
|
|
180
|
+
try {
|
|
181
|
+
const client = makeClient(workspace);
|
|
182
|
+
return jsonResult(
|
|
183
|
+
await client.applyResearchNotePreset(await resolveNodeId(client, nodeId), {
|
|
184
|
+
reviewerUserId,
|
|
185
|
+
noteMeta,
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return errorResult(err);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
server.registerTool(
|
|
195
|
+
'rootr_request_signing_code',
|
|
196
|
+
{
|
|
197
|
+
title: 'Email a signing confirmation code',
|
|
198
|
+
description:
|
|
199
|
+
'Send the 6-digit signing confirmation code to the signer\'s own email. REQUIRED before ' +
|
|
200
|
+
'rootr_submit_signed_document / rootr_sign_document / rootr_reject_signed_document / ' +
|
|
201
|
+
'rootr_bulk_sign_documents — the code is the only evidence that a HUMAN pressed sign, so you cannot ' +
|
|
202
|
+
'read it and must ask the user for it. Valid 10 minutes, 5 wrong tries kill it, and re-requesting ' +
|
|
203
|
+
'within 30 seconds is refused. Pass `bulk: true` (with no nodeId) for one code that covers a bulk sign.',
|
|
204
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
205
|
+
inputSchema: {
|
|
206
|
+
nodeId: z
|
|
207
|
+
.string()
|
|
208
|
+
.optional()
|
|
209
|
+
.describe(`서명문서 노드 id${NODE_TARGET_HINT}. bulk가 true면 생략한다`),
|
|
210
|
+
bulk: z.boolean().optional().describe('묶음 서명용 코드를 받을 때 true'),
|
|
211
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
async ({ nodeId, bulk, workspace }) => {
|
|
215
|
+
try {
|
|
216
|
+
if (bulk) {
|
|
217
|
+
const ws = requireWorkspace(workspace);
|
|
218
|
+
const client = makeClient(ws);
|
|
219
|
+
const out = await client.requestBulkSigningCode(ws);
|
|
220
|
+
return jsonResult({ ...out, next: '사용자에게 메일로 받은 6자리 코드를 물어보세요.' });
|
|
221
|
+
}
|
|
222
|
+
const client = makeClient(workspace);
|
|
223
|
+
if (!nodeId) throw new Error('nodeId 또는 bulk: true 중 하나가 필요합니다.');
|
|
224
|
+
const out = await client.requestSigningCode(await resolveNodeId(client, nodeId));
|
|
225
|
+
return jsonResult({ ...out, next: '사용자에게 메일로 받은 6자리 코드를 물어보세요.' });
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return errorResult(err);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
server.registerTool(
|
|
233
|
+
'rootr_submit_signed_document',
|
|
234
|
+
{
|
|
235
|
+
title: 'Submit (freeze + first-sign) a signed document',
|
|
236
|
+
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.',
|
|
241
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
242
|
+
inputSchema: {
|
|
243
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
244
|
+
code: z.string().describe(CODE_HINT),
|
|
245
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
async ({ nodeId, code, workspace }) => {
|
|
249
|
+
try {
|
|
250
|
+
const client = makeClient(workspace);
|
|
251
|
+
return jsonResult(await client.submitSignedDoc(await resolveNodeId(client, nodeId), { code }));
|
|
252
|
+
} catch (err) {
|
|
253
|
+
return errorResult(err);
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
server.registerTool(
|
|
259
|
+
'rootr_sign_document',
|
|
260
|
+
{
|
|
261
|
+
title: 'Sign a Rootr signed document',
|
|
262
|
+
description:
|
|
263
|
+
'Sign a SIGNED document as the current step\'s signer. Records an Ed25519 signature over the frozen ' +
|
|
264
|
+
'content hash, requests an RFC 3161 timestamp, and links the signature into the workspace hash ledger. ' +
|
|
265
|
+
'Only works when it is your turn (check canSign with rootr_read_signed_document). Needs the emailed ' +
|
|
266
|
+
'code from rootr_request_signing_code — ask the human for it, never invent it.',
|
|
267
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
268
|
+
inputSchema: {
|
|
269
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
270
|
+
code: z.string().describe(CODE_HINT),
|
|
271
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
async ({ nodeId, code, workspace }) => {
|
|
275
|
+
try {
|
|
276
|
+
const client = makeClient(workspace);
|
|
277
|
+
return jsonResult(await client.signSignedDoc(await resolveNodeId(client, nodeId), { code }));
|
|
278
|
+
} catch (err) {
|
|
279
|
+
return errorResult(err);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
server.registerTool(
|
|
285
|
+
'rootr_reject_signed_document',
|
|
286
|
+
{
|
|
287
|
+
title: 'Reject a signed document',
|
|
288
|
+
description:
|
|
289
|
+
'Reject a SIGNED document with a reason. The document unlocks so the author can fix and resubmit; ' +
|
|
290
|
+
'signatures already collected are recorded as part of the history and the rejection itself is ' +
|
|
291
|
+
'notified to the author. The reason is required — "반려됨"만 남으면 고칠 수가 없다.',
|
|
292
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
293
|
+
inputSchema: {
|
|
294
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
295
|
+
comment: z.string().describe('반려 사유 (필수)'),
|
|
296
|
+
code: z.string().optional().describe(CODE_HINT),
|
|
297
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
async ({ nodeId, comment, code, workspace }) => {
|
|
301
|
+
try {
|
|
302
|
+
const client = makeClient(workspace);
|
|
303
|
+
return jsonResult(
|
|
304
|
+
await client.rejectSignedDoc(await resolveNodeId(client, nodeId), { comment, code }),
|
|
305
|
+
);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return errorResult(err);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
server.registerTool(
|
|
313
|
+
'rootr_reopen_signed_document',
|
|
314
|
+
{
|
|
315
|
+
title: 'Reopen an unsigned signed-document draft',
|
|
316
|
+
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).',
|
|
320
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
321
|
+
inputSchema: {
|
|
322
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
323
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
async ({ nodeId, workspace }) => {
|
|
327
|
+
try {
|
|
328
|
+
const client = makeClient(workspace);
|
|
329
|
+
return jsonResult(await client.reopenSignedDoc(await resolveNodeId(client, nodeId)));
|
|
330
|
+
} catch (err) {
|
|
331
|
+
return errorResult(err);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
server.registerTool(
|
|
337
|
+
'rootr_revise_signed_document',
|
|
338
|
+
{
|
|
339
|
+
title: 'Create a revision of a signed document',
|
|
340
|
+
description:
|
|
341
|
+
'Create a NEW revision of an already-signed document. Signed documents are never edited in place — ' +
|
|
342
|
+
'the guideline requires that both the original and the correction stay visible — so this copies the ' +
|
|
343
|
+
'body into a fresh SIGNED node (revisionNo + 1), marks the original SUPERSEDED, and returns the new ' +
|
|
344
|
+
'node id. The reason (note) is recorded on the revision.',
|
|
345
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
346
|
+
inputSchema: {
|
|
347
|
+
nodeId: z.string().describe(`원본 서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
348
|
+
note: z.string().describe('무엇을 왜 고치는지 (필수, 개정본에 남는다)'),
|
|
349
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
async ({ nodeId, note, workspace }) => {
|
|
353
|
+
try {
|
|
354
|
+
const client = makeClient(workspace);
|
|
355
|
+
return jsonResult(await client.reviseSignedDoc(await resolveNodeId(client, nodeId), note));
|
|
356
|
+
} catch (err) {
|
|
357
|
+
return errorResult(err);
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
server.registerTool(
|
|
363
|
+
'rootr_verify_signed_document',
|
|
364
|
+
{
|
|
365
|
+
title: 'Verify a signed document (tamper check)',
|
|
366
|
+
description:
|
|
367
|
+
'Verify a SIGNED document end to end: every Ed25519 signature is re-checked against its payload, ' +
|
|
368
|
+
'every hash-chain link is recomputed, the current body is hashed and compared with the frozen hash, ' +
|
|
369
|
+
'and the workspace ledger is replayed. Returns contentIntact / allSignaturesValid / allTimestamped / ' +
|
|
370
|
+
'ledgerIntact plus per-signature detail. This is the answer to "이 문서가 위조되지 않았음을 증명해 봐".',
|
|
371
|
+
annotations: { readOnlyHint: true },
|
|
372
|
+
inputSchema: {
|
|
373
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
374
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
async ({ nodeId, workspace }) => {
|
|
378
|
+
try {
|
|
379
|
+
const client = makeClient(workspace);
|
|
380
|
+
return jsonResult(await client.verifySignedDoc(await resolveNodeId(client, nodeId)));
|
|
381
|
+
} catch (err) {
|
|
382
|
+
return errorResult(err);
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
server.registerTool(
|
|
388
|
+
'rootr_list_signed_documents',
|
|
389
|
+
{
|
|
390
|
+
title: 'List Rootr signed documents',
|
|
391
|
+
description:
|
|
392
|
+
'List the SIGNED documents of a workspace with their status and who each one is waiting on. ' +
|
|
393
|
+
'Set pendingMine: true for "내 차례인 것만" — the list a 연구책임자 opens to see this week\'s ' +
|
|
394
|
+
'unchecked notes before bulk-signing them.',
|
|
395
|
+
annotations: { readOnlyHint: true },
|
|
396
|
+
inputSchema: {
|
|
397
|
+
status: z
|
|
398
|
+
.enum(['DRAFT', 'IN_PROGRESS', 'COMPLETED', 'REJECTED', 'SUPERSEDED', 'VOIDED'])
|
|
399
|
+
.optional(),
|
|
400
|
+
mode: z.enum(['APPROVAL', 'RESEARCH_NOTE']).optional(),
|
|
401
|
+
pendingMine: z.boolean().optional().describe('내 서명을 기다리는 것만'),
|
|
402
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
async ({ status, mode, pendingMine, workspace }) => {
|
|
406
|
+
try {
|
|
407
|
+
const ws = requireWorkspace(workspace);
|
|
408
|
+
const client = makeClient(ws);
|
|
409
|
+
return jsonResult(await client.listSignedDocs(ws, { status, mode, pendingMine }));
|
|
410
|
+
} catch (err) {
|
|
411
|
+
return errorResult(err);
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
server.registerTool(
|
|
417
|
+
'rootr_bulk_sign_documents',
|
|
418
|
+
{
|
|
419
|
+
title: 'Sign many Rootr documents at once',
|
|
420
|
+
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.',
|
|
425
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
426
|
+
inputSchema: {
|
|
427
|
+
nodeIds: z.array(z.string()).min(1).max(100).describe('서명할 노드 id 목록 (경로도 됩니다)'),
|
|
428
|
+
code: z.string().describe(CODE_HINT + ' (bulk: true 로 받은 코드)'),
|
|
429
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
async ({ nodeIds, code, workspace }) => {
|
|
433
|
+
try {
|
|
434
|
+
const ws = requireWorkspace(workspace);
|
|
435
|
+
const client = makeClient(ws);
|
|
436
|
+
const ids = [];
|
|
437
|
+
for (const t of nodeIds) ids.push(await resolveNodeId(client, t));
|
|
438
|
+
return jsonResult(await client.bulkSignSignedDocs(ws, { nodeIds: ids, code }));
|
|
439
|
+
} catch (err) {
|
|
440
|
+
return errorResult(err);
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
server.registerTool(
|
|
446
|
+
'rootr_verify_ledger',
|
|
447
|
+
{
|
|
448
|
+
title: 'Verify the workspace signature ledger',
|
|
449
|
+
description:
|
|
450
|
+
'Replay the whole workspace signature ledger (every signature ever made, hash-chained in order) and ' +
|
|
451
|
+
'report whether it is intact, how many links it holds, its head hash, and the first broken sequence ' +
|
|
452
|
+
'if any. A broken ledger means someone edited or deleted signature rows in the database.',
|
|
453
|
+
annotations: { readOnlyHint: true },
|
|
454
|
+
inputSchema: {
|
|
455
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
async ({ workspace }) => {
|
|
459
|
+
try {
|
|
460
|
+
const ws = requireWorkspace(workspace);
|
|
461
|
+
return jsonResult(await makeClient(ws).verifyLedger(ws));
|
|
462
|
+
} catch (err) {
|
|
463
|
+
return errorResult(err);
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
}
|
|
@@ -14,7 +14,14 @@ import { parseRootrUrl } from '../resolve.js';
|
|
|
14
14
|
* branch plus parseTree() keeps any client that still stringifies working.
|
|
15
15
|
* Mirrors the typed-array pattern already used in logs.js.
|
|
16
16
|
*/
|
|
17
|
-
const
|
|
17
|
+
const ACCESS_HINT =
|
|
18
|
+
' Any node may also carry `visibility: "PRIVATE"` (only its creator, workspace admins, and people listed in ' +
|
|
19
|
+
'`access` can see it and everything under it) plus `access: [{ subjectId: "<userId>", level: "READ"|"WRITE"|' +
|
|
20
|
+
'"MANAGE" }]` — that pair is how "make a folder these three people can see" happens in one call instead of ' +
|
|
21
|
+
'leaving the locking to the user afterwards. A DATABASE node may also carry `database.permissions` (칸/행 ' +
|
|
22
|
+
'lock) and `database.notifications` (who to notify on row changes).';
|
|
23
|
+
|
|
24
|
+
const treeArg = (desc) => z.array(z.record(z.any())).or(z.string()).describe(desc + ACCESS_HINT);
|
|
18
25
|
|
|
19
26
|
/** Accept either a real array (typed clients) or a JSON string (clients that stringify structured args). */
|
|
20
27
|
function parseTree(tree) {
|
package/lib/mcp.js
CHANGED
|
@@ -17,6 +17,7 @@ import { registerWhiteboardTools } from './mcp-tools/whiteboards.js';
|
|
|
17
17
|
import { registerFormTools } from './mcp-tools/forms.js';
|
|
18
18
|
import { registerRecordingTools } from './mcp-tools/recordings.js';
|
|
19
19
|
import { registerAttachmentTools } from './mcp-tools/attachments.js';
|
|
20
|
+
import { registerSignedDocTools } from './mcp-tools/signed.js';
|
|
20
21
|
import { withRateLimitSink } from './context.js';
|
|
21
22
|
|
|
22
23
|
/** Warn once the workspace has burned this share of its quota. */
|
|
@@ -64,7 +65,7 @@ function withQuotaWarnings(server) {
|
|
|
64
65
|
* Builds a fully-configured McpServer (all tool groups registered) without
|
|
65
66
|
* connecting a transport. Shared by the local stdio CLI server below and by
|
|
66
67
|
* the backend's hosted Streamable HTTP transport (src/mcp/mcp-transport.controller.ts),
|
|
67
|
-
* so the
|
|
68
|
+
* so the 134 tool definitions have exactly one implementation.
|
|
68
69
|
*/
|
|
69
70
|
export function buildMcpServer({ name, version }) {
|
|
70
71
|
const server = new McpServer(
|
|
@@ -111,6 +112,16 @@ export function buildMcpServer({ name, version }) {
|
|
|
111
112
|
'and spends AI credits) → poll rootr_read_recording until status READY → read the transcript, ' +
|
|
112
113
|
'rename speakers with rootr_rename_recording_speakers, and write the minutes yourself with rootr_write ' +
|
|
113
114
|
'(preferred — no AI credits) or call rootr_summarize_recording for Rootr\'s built-in summary document; ' +
|
|
115
|
+
'SIGNED = 서명문서 · 전자결재 · 국가R&D 전자연구노트 — a markdown document that FREEZES on submit, ' +
|
|
116
|
+
'collects ordered Ed25519 signatures with RFC 3161 timestamps, and is hash-chained into a workspace ' +
|
|
117
|
+
'ledger so tampering is provable; cannot be deleted (30-year retention). Use it whenever the content ' +
|
|
118
|
+
'must be provable — 결재/승인 문서, 연구노트, 점검 기록 — via rootr_create_signed_document, ' +
|
|
119
|
+
'rootr_read_signed_document, rootr_set_approval_line, rootr_apply_research_note_preset, ' +
|
|
120
|
+
'rootr_submit_signed_document, rootr_sign_document, rootr_reject_signed_document, ' +
|
|
121
|
+
'rootr_revise_signed_document, rootr_list_signed_documents, rootr_bulk_sign_documents, ' +
|
|
122
|
+
'rootr_verify_signed_document, rootr_verify_ledger. NEVER invent a signing code: always ' +
|
|
123
|
+
'rootr_request_signing_code first and ASK THE HUMAN for the 6 digits that arrive in their mail — ' +
|
|
124
|
+
'that code is the only evidence a person (not an agent) pressed sign; ' +
|
|
114
125
|
'FOLDER = grouping; LOG store = typed event table whose relation fields auto-build a lineage graph (metrics, incidents, pipeline events — use the rootr_*_log_* tools).\n\n' +
|
|
115
126
|
'Scaffolding discipline: ALWAYS rootr_scaffold_plan first, ask the user the 3-10 clarifying questions it ' +
|
|
116
127
|
'prescribes, and only then design and create the tree — every answer must visibly shape the structure. ' +
|
|
@@ -138,6 +149,7 @@ export function buildMcpServer({ name, version }) {
|
|
|
138
149
|
registerFormTools(server);
|
|
139
150
|
registerRecordingTools(server);
|
|
140
151
|
registerAttachmentTools(server);
|
|
152
|
+
registerSignedDocTools(server);
|
|
141
153
|
|
|
142
154
|
return server;
|
|
143
155
|
}
|