rootr-cli 0.14.0 → 0.16.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 +116 -5
- package/bin/rootr.js +354 -11
- package/lib/client.js +117 -6
- package/lib/mcp-tools/databases.js +144 -3
- package/lib/mcp-tools/documents.js +22 -5
- package/lib/mcp-tools/forms.js +2 -1
- package/lib/mcp-tools/memory.js +185 -0
- package/lib/mcp-tools/misc.js +43 -6
- package/lib/mcp-tools/pages.js +239 -0
- package/lib/mcp-tools/recordings.js +2 -0
- package/lib/mcp-tools/spreadsheets.js +2 -1
- package/lib/mcp-tools/templates.js +2 -1
- package/lib/mcp-tools/whiteboards.js +2 -1
- package/lib/mcp.js +90 -0
- package/package.json +1 -1
package/lib/mcp-tools/misc.js
CHANGED
|
@@ -95,11 +95,12 @@ export function registerMiscTools(server) {
|
|
|
95
95
|
{
|
|
96
96
|
title: 'Rename / re-icon / move any node',
|
|
97
97
|
description:
|
|
98
|
-
'Change a Rootr (루터) node\'s NAME, ICON, TAGS, or
|
|
99
|
-
'(DOCUMENT, FOLDER, DATABASE, SPREADSHEET, FORM, …). Until now icons could only be set when a node ' +
|
|
98
|
+
'Change a Rootr (루터) node\'s NAME, ICON, TAGS, PARENT, or graph auto-extraction setting — works for every ' +
|
|
99
|
+
'node type (DOCUMENT, FOLDER, DATABASE, SPREADSHEET, FORM, …). Until now icons could only be set when a node ' +
|
|
100
100
|
'was CREATED, so matching a migrated tree to its Notion icons was impossible without recreating ' +
|
|
101
101
|
'everything. Only the fields you pass change. `parentId: null` moves the node to the workspace root; ' +
|
|
102
|
-
'a node cannot be moved inside its own subtree.'
|
|
102
|
+
'a node cannot be moved inside its own subtree. `graphSyncOff: true` stops rows/edits from queuing this ' +
|
|
103
|
+
'node (and DATABASE tables especially) for graph extraction — useful right before a bulk load.',
|
|
103
104
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
104
105
|
inputSchema: {
|
|
105
106
|
target: z.string().describe('Node path (starting with "/") or node id'),
|
|
@@ -111,9 +112,16 @@ export function registerMiscTools(server) {
|
|
|
111
112
|
.nullable()
|
|
112
113
|
.optional()
|
|
113
114
|
.describe('Move under this folder/document/database (id or path). null = workspace root.'),
|
|
115
|
+
graphSyncOff: z
|
|
116
|
+
.boolean()
|
|
117
|
+
.optional()
|
|
118
|
+
.describe(
|
|
119
|
+
'true = stop queuing this node for graph extraction (embeddings + LLM) on every edit; false = ' +
|
|
120
|
+
're-enable it (and backfill affected documents).',
|
|
121
|
+
),
|
|
114
122
|
},
|
|
115
123
|
},
|
|
116
|
-
async ({ target, name, icon, tags, parentId }) => {
|
|
124
|
+
async ({ target, name, icon, tags, parentId, graphSyncOff }) => {
|
|
117
125
|
try {
|
|
118
126
|
const client = makeClient();
|
|
119
127
|
const id = requireResolved(await resolveTarget(client, target));
|
|
@@ -122,7 +130,7 @@ export function registerMiscTools(server) {
|
|
|
122
130
|
if (typeof parentId === 'string' && parentId) {
|
|
123
131
|
parent = requireResolved(await resolveTarget(client, parentId));
|
|
124
132
|
}
|
|
125
|
-
const node = await client.updateNode(id, { name, icon, tags, parentId: parent });
|
|
133
|
+
const node = await client.updateNode(id, { name, icon, tags, parentId: parent, graphSyncOff });
|
|
126
134
|
return jsonResult(node);
|
|
127
135
|
} catch (err) {
|
|
128
136
|
return errorResult(err);
|
|
@@ -148,7 +156,36 @@ export function registerMiscTools(server) {
|
|
|
148
156
|
const resolved = await resolveTarget(client, target);
|
|
149
157
|
const id = requireResolved(resolved);
|
|
150
158
|
await client.deleteNode(id);
|
|
151
|
-
return jsonResult({
|
|
159
|
+
return jsonResult({
|
|
160
|
+
deleted: id,
|
|
161
|
+
note: 'moved to trash, recoverable — rootr_restore_node puts it back',
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return errorResult(err);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
server.registerTool(
|
|
170
|
+
'rootr_restore_node',
|
|
171
|
+
{
|
|
172
|
+
title: 'Restore a deleted node from the trash',
|
|
173
|
+
description:
|
|
174
|
+
'Put a trashed Rootr (루터) document, folder or table back where it was — the undo for ' +
|
|
175
|
+
'rootr_delete_node, and the same thing Ctrl+Z does in the app. Pass the node id you got back ' +
|
|
176
|
+
'from the delete (a path will NOT find it: a trashed node is no longer on the tree). ' +
|
|
177
|
+
'Children that went to the trash with it come back too. Needs WRITE on the node.',
|
|
178
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
179
|
+
inputSchema: {
|
|
180
|
+
nodeId: z
|
|
181
|
+
.string()
|
|
182
|
+
.describe('Node id of the trashed item (from the rootr_delete_node result or a notification)'),
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
async ({ nodeId }) => {
|
|
186
|
+
try {
|
|
187
|
+
const client = makeClient();
|
|
188
|
+
return jsonResult(await client.restoreNode(nodeId));
|
|
152
189
|
} catch (err) {
|
|
153
190
|
return errorResult(err);
|
|
154
191
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
jsonResult,
|
|
5
|
+
errorResult,
|
|
6
|
+
makeClient,
|
|
7
|
+
requireWorkspace,
|
|
8
|
+
resolveNodeId,
|
|
9
|
+
resolveOptionalNodeId,
|
|
10
|
+
NODE_TARGET_HINT,
|
|
11
|
+
} from './shared.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* PAGE (web page) tools.
|
|
15
|
+
*
|
|
16
|
+
* A Rootr page is an ordered list of typed blocks — that is the safety model:
|
|
17
|
+
* a published page cannot carry a script or a phishing overlay. The agent's job
|
|
18
|
+
* is to CHOOSE BLOCKS. The one escape hatch is the `custom` block, whose free
|
|
19
|
+
* HTML/CSS renders inside a locked-down sandboxed iframe (no scripts, opaque
|
|
20
|
+
* origin, no network), so even bold CSS stays inside its own box.
|
|
21
|
+
*
|
|
22
|
+
* The guide below is repeated (shortened) on every tool, so an agent that only
|
|
23
|
+
* sees one of these tools still knows the block vocabulary.
|
|
24
|
+
*/
|
|
25
|
+
const BLOCK_GUIDE =
|
|
26
|
+
'A page is `blocks[]` (ordered, max 200) + `config`. Each block is {id?, type, props}. ' +
|
|
27
|
+
'You assemble a page from these 15 block types:\n' +
|
|
28
|
+
'· hero {title, subtitle?, align?:"left"|"center", background?:{kind:"color",color}|{kind:"image",attachmentId}, button?:{label,target}} — the opening banner\n' +
|
|
29
|
+
'· text {body (markdown), title?} — prose section; markdown is sanitized when rendered\n' +
|
|
30
|
+
'· features {columns?:1|2|3, items:[{title, body, icon?(emoji)}]} — "what it does" grid\n' +
|
|
31
|
+
'· gallery {images:[{attachmentId, caption?}]} — images must be workspace attachments (no external image URLs)\n' +
|
|
32
|
+
'· pricing {plans:[{name, price, period?, features:[string], highlight?, buttonLabel?, target?}]}\n' +
|
|
33
|
+
'· testimonials {items:[{quote, name, affiliation?}]}\n' +
|
|
34
|
+
'· faq {items:[{q, a}]}\n' +
|
|
35
|
+
'· timeline {items:[{date, title, body?}]}\n' +
|
|
36
|
+
'· cta {title, buttonLabel, target, body?} — closing call to action\n' +
|
|
37
|
+
'· footer {links:[{label, target}], contact?, copyright?}\n' +
|
|
38
|
+
'· liveStats {cards:[{label, icon?, source:{nodeId(DATABASE/LOG node), agg:"count"|"sum"|"avg"|"min"|"max", column?}}]} — numbers read LIVE from a table\n' +
|
|
39
|
+
'· liveTable {source:{nodeId}, columns?:[name], rowLimit?(1-200), orderBy?:{column, dir:"asc"|"desc"}}\n' +
|
|
40
|
+
'· liveChart {source:{nodeId}, chart:"bar"|"line"|"donut", labelColumn, valueColumn, agg:"count"|"sum"|"avg"}\n' +
|
|
41
|
+
'· form {formNodeId(FORM node), headline?, submitLabel?} — visitors submit right on the page\n' +
|
|
42
|
+
'· custom {html, height?(40-4000, default 260)} — free HTML/CSS, rendered inside a locked-down sandboxed iframe ' +
|
|
43
|
+
'(no scripts, no same-origin, no network). Use it for a look the other blocks cannot make. Page theme colors are ' +
|
|
44
|
+
'available inside as var(--pg-bg|text|muted|faint|card|line|accent|on-accent) and var(--pg-font). Scripts and ' +
|
|
45
|
+
'on*= handlers are stripped on save — do not put behaviour here, only markup and CSS.\n' +
|
|
46
|
+
'LIVE/FORM BLOCKS: `source.nodeId` / `formNodeId` must be a node in the SAME workspace, and `column`/`labelColumn`/' +
|
|
47
|
+
'`valueColumn`/`orderBy.column` are the column NAMES as shown in the table (not column ids). A table or form that ' +
|
|
48
|
+
'sits in a PRIVATE subtree is silently skipped on the published page — that is deliberate, never a bug to work around.\n' +
|
|
49
|
+
'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only) or {kind:"node", nodeId} ' +
|
|
50
|
+
'for a document in the same workspace. Internal path strings are rejected. A node link renders as the in-app ' +
|
|
51
|
+
'document for members, as the public address on the published page, and is hidden entirely if that document is ' +
|
|
52
|
+
'not published — so link to nodes rather than pasting addresses.\n' +
|
|
53
|
+
'CONFIG: {theme, accentColor?, navEnabled?}. theme is one of classic | minimal | warm | pastel | editorial | mono | ' +
|
|
54
|
+
'dark | midnight. accentColor is a hex like "#1A4A7A"; leave it out and the theme picks a matching accent. ' +
|
|
55
|
+
'Every published page carries the Rootr signature at the bottom.\n' +
|
|
56
|
+
'Block ids are optional — omit them and they are generated. Keep the ids you get back if you want to edit one block later.';
|
|
57
|
+
|
|
58
|
+
/** PAGE (block-assembled web page) node tools: create/read/update/append + live values. */
|
|
59
|
+
export function registerPageTools(server) {
|
|
60
|
+
server.registerTool(
|
|
61
|
+
'rootr_create_page',
|
|
62
|
+
{
|
|
63
|
+
title: 'Create a Rootr page (block-assembled web page)',
|
|
64
|
+
description:
|
|
65
|
+
'Create a new PAGE node in a Rootr (루터) workspace — a web page assembled from safe typed blocks, which ' +
|
|
66
|
+
'can then be published to the web at a public address (see rootr_publish_node). Use this when someone asks ' +
|
|
67
|
+
'for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
|
|
68
|
+
'Omit `blocks` to start from a hero/text/footer skeleton. ' +
|
|
69
|
+
BLOCK_GUIDE,
|
|
70
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
71
|
+
inputSchema: {
|
|
72
|
+
name: z.string().optional().describe('Page name (also the browser tab title of the published page)'),
|
|
73
|
+
parentId: z.string().optional().describe(`Parent folder/node id${NODE_TARGET_HINT}`),
|
|
74
|
+
path: z.string().optional().describe('Full path including the name, e.g. "/마케팅/가을 행사" — wins over parentId+name'),
|
|
75
|
+
createParents: z.boolean().optional().describe('Create missing parent folders in `path`'),
|
|
76
|
+
autoRename: z.boolean().optional().describe('On a name clash, append " (2)" instead of failing with 409'),
|
|
77
|
+
icon: z.string().optional().describe('Emoji icon for the page node'),
|
|
78
|
+
blocks: z.array(z.record(z.unknown())).optional().describe('Ordered blocks — see the block vocabulary in this description'),
|
|
79
|
+
config: z
|
|
80
|
+
.record(z.unknown())
|
|
81
|
+
.optional()
|
|
82
|
+
.describe('{theme, accentColor?, navEnabled?} — theme: classic|minimal|warm|pastel|editorial|mono|dark|midnight'),
|
|
83
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
async ({ name, parentId, path, createParents, autoRename, icon, blocks, config, workspace }) => {
|
|
87
|
+
try {
|
|
88
|
+
const ws = requireWorkspace(workspace);
|
|
89
|
+
const client = makeClient(ws);
|
|
90
|
+
parentId = await resolveOptionalNodeId(client, parentId);
|
|
91
|
+
return jsonResult(
|
|
92
|
+
await client.createPage(ws, { name, parentId, path, createParents, autoRename, icon, blocks, config }),
|
|
93
|
+
);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return errorResult(err);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
server.registerTool(
|
|
101
|
+
'rootr_read_page',
|
|
102
|
+
{
|
|
103
|
+
title: 'Read a Rootr page',
|
|
104
|
+
description:
|
|
105
|
+
'Read a Rootr (루터) PAGE node — its full blocks array and config. Read this before rootr_update_page or ' +
|
|
106
|
+
'rootr_update_page_block so you know the current block ids, order and contents.',
|
|
107
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
108
|
+
inputSchema: {
|
|
109
|
+
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
async ({ pageId }) => {
|
|
113
|
+
try {
|
|
114
|
+
const client = makeClient();
|
|
115
|
+
pageId = await resolveNodeId(client, pageId);
|
|
116
|
+
return jsonResult(await client.getPage(pageId));
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return errorResult(err);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
server.registerTool(
|
|
124
|
+
'rootr_append_page_blocks',
|
|
125
|
+
{
|
|
126
|
+
title: 'Append blocks to a Rootr page (preferred way to add sections)',
|
|
127
|
+
description:
|
|
128
|
+
'Append one or more blocks to the END of a Rootr (루터) PAGE, leaving existing blocks untouched. ' +
|
|
129
|
+
'PREFER this over rootr_update_page when you are only adding sections — update replaces the whole page. ' +
|
|
130
|
+
BLOCK_GUIDE,
|
|
131
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
132
|
+
inputSchema: {
|
|
133
|
+
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
134
|
+
blocks: z.array(z.record(z.unknown())).describe('Blocks to append, in order'),
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
async ({ pageId, blocks }) => {
|
|
138
|
+
try {
|
|
139
|
+
const client = makeClient();
|
|
140
|
+
pageId = await resolveNodeId(client, pageId);
|
|
141
|
+
return jsonResult(await client.appendPageBlocks(pageId, blocks));
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return errorResult(err);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
server.registerTool(
|
|
149
|
+
'rootr_update_page',
|
|
150
|
+
{
|
|
151
|
+
title: 'Update a Rootr page (blocks/theme)',
|
|
152
|
+
description:
|
|
153
|
+
'Update a Rootr (루터) PAGE. Passing `blocks` REPLACES THE WHOLE PAGE — call rootr_read_page first and ' +
|
|
154
|
+
'resend every block you want to keep, or use rootr_append_page_blocks / rootr_update_page_block for ' +
|
|
155
|
+
'incremental edits. Passing only `config` changes the theme/accent without touching content. ' +
|
|
156
|
+
BLOCK_GUIDE,
|
|
157
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
158
|
+
inputSchema: {
|
|
159
|
+
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
160
|
+
blocks: z
|
|
161
|
+
.array(z.record(z.unknown()))
|
|
162
|
+
.optional()
|
|
163
|
+
.describe('COMPLETE blocks array to set — any block you omit is deleted'),
|
|
164
|
+
config: z.record(z.unknown()).optional().describe('{theme, accentColor?, navEnabled?}'),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
async ({ pageId, blocks, config }) => {
|
|
168
|
+
try {
|
|
169
|
+
const client = makeClient();
|
|
170
|
+
pageId = await resolveNodeId(client, pageId);
|
|
171
|
+
return jsonResult(await client.updatePage(pageId, { blocks, config }));
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return errorResult(err);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
server.registerTool(
|
|
179
|
+
'rootr_update_page_block',
|
|
180
|
+
{
|
|
181
|
+
title: 'Edit one block of a Rootr page',
|
|
182
|
+
description:
|
|
183
|
+
'Replace the props of a SINGLE block on a Rootr (루터) PAGE, leaving every other block alone — the safe way ' +
|
|
184
|
+
'to fix one section (a headline, a price, a live table source). The block keeps its id and position. ' +
|
|
185
|
+
'Get block ids from rootr_read_page. ' +
|
|
186
|
+
BLOCK_GUIDE,
|
|
187
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
188
|
+
inputSchema: {
|
|
189
|
+
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
190
|
+
blockId: z.string().describe('Block id, from rootr_read_page'),
|
|
191
|
+
props: z.record(z.unknown()).describe('COMPLETE props object for this block type (replaces the old props)'),
|
|
192
|
+
type: z.string().optional().describe('Change the block type too (rare) — props must then match the new type'),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
async ({ pageId, blockId, props, type }) => {
|
|
196
|
+
try {
|
|
197
|
+
const client = makeClient();
|
|
198
|
+
pageId = await resolveNodeId(client, pageId);
|
|
199
|
+
const page = await client.getPage(pageId);
|
|
200
|
+
const blocks = Array.isArray(page.blocks) ? page.blocks : [];
|
|
201
|
+
const idx = blocks.findIndex((b) => b && b.id === blockId);
|
|
202
|
+
if (idx === -1) {
|
|
203
|
+
// 있는 id를 알려 준다 — 못 찾았다고만 하면 다음 시도도 똑같이 틀린다.
|
|
204
|
+
const ids = blocks.map((b) => `${b.id}(${b.type})`).join(', ') || '(none)';
|
|
205
|
+
return errorResult(new Error(`block "${blockId}" not found on this page. Blocks: ${ids}`));
|
|
206
|
+
}
|
|
207
|
+
const next = blocks.map((b, i) => (i === idx ? { ...b, ...(type ? { type } : {}), props } : b));
|
|
208
|
+
return jsonResult(await client.updatePage(pageId, { blocks: next }));
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return errorResult(err);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
server.registerTool(
|
|
216
|
+
'rootr_read_page_live_data',
|
|
217
|
+
{
|
|
218
|
+
title: 'Read what a page\'s live blocks currently show',
|
|
219
|
+
description:
|
|
220
|
+
'Read the values a Rootr (루터) PAGE\'s live blocks (liveStats/liveTable/liveChart) are showing right now — ' +
|
|
221
|
+
'the aggregates, rows and chart points computed from their source tables. Use it to check that a live block ' +
|
|
222
|
+
'points at the right table and column: an empty result usually means the source is missing, is in a PRIVATE ' +
|
|
223
|
+
'subtree, or the column name does not match.',
|
|
224
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
225
|
+
inputSchema: {
|
|
226
|
+
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
async ({ pageId }) => {
|
|
230
|
+
try {
|
|
231
|
+
const client = makeClient();
|
|
232
|
+
pageId = await resolveNodeId(client, pageId);
|
|
233
|
+
return jsonResult(await client.getPageLiveData(pageId));
|
|
234
|
+
} catch (err) {
|
|
235
|
+
return errorResult(err);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -208,6 +208,7 @@ export function registerRecordingTools(server) {
|
|
|
208
208
|
'UPLOADED = audio stored but no transcript yet — call rootr_transcribe_recording to create one; ' +
|
|
209
209
|
'TRANSCRIBING = speech-to-text still running (poll again later); ' +
|
|
210
210
|
'READY = transcript available; FAILED = last transcription errored (see `error`).',
|
|
211
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
211
212
|
inputSchema: {
|
|
212
213
|
recordingId: z.string().describe(`RECORDING node id${NODE_TARGET_HINT}`),
|
|
213
214
|
},
|
|
@@ -242,6 +243,7 @@ export function registerRecordingTools(server) {
|
|
|
242
243
|
'Assign display names to the diarized speaker labels of a Rootr (루터) RECORDING node, e.g. ' +
|
|
243
244
|
'{"S1":"김대표","S2":"이팀장"}. Merge-patch: only the keys you send change. The names apply to the ' +
|
|
244
245
|
'transcript everywhere (web UI, rootr_read_recording, knowledge graph).',
|
|
246
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
245
247
|
inputSchema: {
|
|
246
248
|
recordingId: z.string().describe(`RECORDING node id${NODE_TARGET_HINT}`),
|
|
247
249
|
speakerNames: z
|
|
@@ -88,7 +88,8 @@ export function registerSpreadsheetTools(server) {
|
|
|
88
88
|
'Merge-patch a Rootr (루터) SPREADSHEET: update config/icon, or REPLACE THE WHOLE sheets array (if you ' +
|
|
89
89
|
'pass `sheets`, it replaces every existing sheet — call rootr_read_spreadsheet first and resend sheets ' +
|
|
90
90
|
'you want to keep, or prefer rootr_create_sheet / rootr_patch_spreadsheet_cells for incremental changes).',
|
|
91
|
-
|
|
91
|
+
// `sheets`를 주면 시트 배열 전체를 갈아끼운다 — 안 보낸 시트는 사라진다.
|
|
92
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
92
93
|
inputSchema: {
|
|
93
94
|
spreadsheetId: z.string().describe(`SPREADSHEET node id${NODE_TARGET_HINT}`),
|
|
94
95
|
sheets: z
|
|
@@ -132,7 +132,8 @@ export function registerTemplateTools(server) {
|
|
|
132
132
|
'Change a Rootr (루터) template — its name, emoji, description or `spec`. Editing a builtin transparently ' +
|
|
133
133
|
'creates a workspace copy, so the builtin itself is never damaged. Use this for "change the standard format" ' +
|
|
134
134
|
'requests instead of saving a second near-identical template.',
|
|
135
|
-
|
|
135
|
+
// `spec`을 주면 템플릿 정의를 통째로 덮어쓴다(버전 기록 없음).
|
|
136
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
136
137
|
inputSchema: {
|
|
137
138
|
templateId: z.string().describe('Template id from rootr_list_templates'),
|
|
138
139
|
name: z.string().optional().describe('New name'),
|
|
@@ -139,7 +139,8 @@ export function registerWhiteboardTools(server) {
|
|
|
139
139
|
'content). merge=false/omitted REPLACES the whole scene — call rootr_read_whiteboard first and resend ' +
|
|
140
140
|
'anything you want to keep. ' +
|
|
141
141
|
AUTHORING_GUIDE,
|
|
142
|
-
|
|
142
|
+
// merge=false 면 장면 전체를 갈아끼운다 — 사람이 손으로 그린 것까지 날아간다.
|
|
143
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
143
144
|
inputSchema: {
|
|
144
145
|
whiteboardId: z.string().describe(`WHITEBOARD node id${NODE_TARGET_HINT}`),
|
|
145
146
|
scene: z
|
package/lib/mcp.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerMiscTools } from './mcp-tools/misc.js';
|
|
|
13
13
|
import { registerPublicTools } from './mcp-tools/public.js';
|
|
14
14
|
import { registerCrmTools } from './mcp-tools/crm.js';
|
|
15
15
|
import { registerPresentationTools } from './mcp-tools/presentations.js';
|
|
16
|
+
import { registerPageTools } from './mcp-tools/pages.js';
|
|
16
17
|
import { registerSpreadsheetTools } from './mcp-tools/spreadsheets.js';
|
|
17
18
|
import { registerWhiteboardTools } from './mcp-tools/whiteboards.js';
|
|
18
19
|
import { registerFormTools } from './mcp-tools/forms.js';
|
|
@@ -20,7 +21,9 @@ import { registerRecordingTools } from './mcp-tools/recordings.js';
|
|
|
20
21
|
import { registerAttachmentTools } from './mcp-tools/attachments.js';
|
|
21
22
|
import { registerSignedDocTools } from './mcp-tools/signed.js';
|
|
22
23
|
import { registerFeedbackTools } from './mcp-tools/feedback.js';
|
|
24
|
+
import { registerMemoryTools } from './mcp-tools/memory.js';
|
|
23
25
|
import { withRateLimitSink } from './context.js';
|
|
26
|
+
import { makeClient } from './mcp-tools/shared.js';
|
|
24
27
|
|
|
25
28
|
/** Warn once the workspace has burned this share of its quota. */
|
|
26
29
|
const QUOTA_WARN_AT = 0.8;
|
|
@@ -63,6 +66,88 @@ function withQuotaWarnings(server) {
|
|
|
63
66
|
return server;
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* MEM-001 — **기억을 눈앞에 놓는다. 이 기능의 성패가 여기 달렸다.**
|
|
72
|
+
*
|
|
73
|
+
* 도구만 만들어 두면 클로드는 안 부른다. 이미 두 번 겪었다 — 고객센터에서
|
|
74
|
+
* 프롬프트로 "어려우면 넘겨라"라고 시켰는데 말만 하고 도구를 안 불러 티켓이 0건이던
|
|
75
|
+
* 일, 어시스턴트 답변 언어를 결국 코드로 강제해야 했던 일. **프롬프트로 시킨 건
|
|
76
|
+
* 안 지켜진다.**
|
|
77
|
+
*
|
|
78
|
+
* 그래서 회상을 클로드의 선의에 맡기지 않는다. 이 워크스페이스에서 **첫 도구를
|
|
79
|
+
* 부르는 순간** 응답 꼬리에 기억 목록을 통째로 실어 보낸다(무료 한도 안내를 얹는
|
|
80
|
+
* 바로 그 자리 — 클로드가 반드시 읽는 곳이다).
|
|
81
|
+
*
|
|
82
|
+
* 한 번만 얹는다. 매 응답마다 붙이면 대화가 길어질수록 같은 목록이 수십 번 쌓여
|
|
83
|
+
* 컨텍스트를 잡아먹고, 그러면 오히려 안 읽는다.
|
|
84
|
+
*
|
|
85
|
+
* ★ 이 자동 회상은 무료 하루 한도에 안 든다 — 서버 쪽 회상 엔드포인트에
|
|
86
|
+
* `@NotRateLimited()`가 걸려 있다(memory.controller.ts). 사용자가 시키지도 않은
|
|
87
|
+
* 호출로 한도가 닳으면 쓰지도 않았는데 막히는 꼴이 되기 때문이다.
|
|
88
|
+
*/
|
|
89
|
+
const memoryPrimed = new Set();
|
|
90
|
+
|
|
91
|
+
/** 기억 목록을 클로드가 읽을 한 덩어리 글로 만든다. */
|
|
92
|
+
function memoryBriefing(memories) {
|
|
93
|
+
if (!memories?.length) return null;
|
|
94
|
+
const live = memories.filter((m) => m.status !== '폐기');
|
|
95
|
+
if (!live.length) return null;
|
|
96
|
+
const line = (m) => {
|
|
97
|
+
const flags = [];
|
|
98
|
+
if (m.status === '확인필요') flags.push('확인필요');
|
|
99
|
+
const tail = flags.length ? ` [${flags.join('·')}]` : '';
|
|
100
|
+
const src = m.source ? ` (출처: ${m.source})` : '';
|
|
101
|
+
return `- ${m.content}${tail}${src}`;
|
|
102
|
+
};
|
|
103
|
+
return (
|
|
104
|
+
`\n\n---\n🧠 이 워크스페이스가 이미 아는 것 (${live.length}건) — 일을 시작하기 전에 읽으세요.\n` +
|
|
105
|
+
live.map(line).join('\n') +
|
|
106
|
+
`\n"확인필요"는 출처 문서가 바뀌었다는 뜻입니다 — 맞는지 확인하고 쓰세요.\n` +
|
|
107
|
+
`새로 알게 된 규칙·결정·함정이 있으면 rootr_remember로 한 줄 남겨 두세요.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function withMemoryRecall(server) {
|
|
112
|
+
const original = server.registerTool.bind(server);
|
|
113
|
+
server.registerTool = (name, spec, handler) =>
|
|
114
|
+
original(name, spec, async (...args) => {
|
|
115
|
+
const result = await handler(...args);
|
|
116
|
+
// 기억 도구 자신에게 기억을 얹으면 같은 목록이 두 번 나온다.
|
|
117
|
+
if (name === 'rootr_recall' || name === 'rootr_remember' || name === 'rootr_forget') return result;
|
|
118
|
+
if (!result || result.isError || !Array.isArray(result.content)) return result;
|
|
119
|
+
|
|
120
|
+
let client;
|
|
121
|
+
try {
|
|
122
|
+
client = makeClient(args?.[0]?.workspace);
|
|
123
|
+
} catch {
|
|
124
|
+
return result; // 워크스페이스가 안 정해진 도구(설정·워크스페이스 목록 등)
|
|
125
|
+
}
|
|
126
|
+
const ws = client.workspace;
|
|
127
|
+
if (!ws || memoryPrimed.has(ws)) return result;
|
|
128
|
+
memoryPrimed.add(ws);
|
|
129
|
+
|
|
130
|
+
let briefing = null;
|
|
131
|
+
try {
|
|
132
|
+
// 한도 제외는 서버가 건다(@NotRateLimited). 여기서는 그냥 부르면 된다.
|
|
133
|
+
const recalled = await client.recall({ limit: 200 });
|
|
134
|
+
briefing = memoryBriefing(recalled?.memories);
|
|
135
|
+
} catch {
|
|
136
|
+
// 기억을 못 읽는다고 원래 하던 일이 실패하면 안 된다. 조용히 넘어간다.
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
if (!briefing) return result;
|
|
140
|
+
|
|
141
|
+
const last = [...result.content].reverse().find((c) => c && c.type === 'text');
|
|
142
|
+
if (!last) return result;
|
|
143
|
+
return {
|
|
144
|
+
...result,
|
|
145
|
+
content: result.content.map((c) => (c === last ? { ...c, text: `${c.text}${briefing}` } : c)),
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
return server;
|
|
149
|
+
}
|
|
150
|
+
|
|
66
151
|
/**
|
|
67
152
|
* Builds a fully-configured McpServer (all tool groups registered) without
|
|
68
153
|
* connecting a transport. Shared by the local stdio CLI server below and by
|
|
@@ -160,6 +245,9 @@ export function buildMcpServer({ name, version }) {
|
|
|
160
245
|
},
|
|
161
246
|
);
|
|
162
247
|
|
|
248
|
+
// 순서가 중요하다. 나중에 감싼 쪽이 바깥이므로, 기억 브리핑을 먼저 붙이고
|
|
249
|
+
// 한도 경고가 그 뒤에 붙는다 — 경고는 언제나 맨 끝에 있어야 눈에 띈다.
|
|
250
|
+
withMemoryRecall(server);
|
|
163
251
|
withQuotaWarnings(server);
|
|
164
252
|
|
|
165
253
|
registerDocumentTools(server);
|
|
@@ -174,6 +262,7 @@ export function buildMcpServer({ name, version }) {
|
|
|
174
262
|
registerPublicTools(server);
|
|
175
263
|
registerCrmTools(server);
|
|
176
264
|
registerPresentationTools(server);
|
|
265
|
+
registerPageTools(server);
|
|
177
266
|
registerSpreadsheetTools(server);
|
|
178
267
|
registerWhiteboardTools(server);
|
|
179
268
|
registerFormTools(server);
|
|
@@ -181,6 +270,7 @@ export function buildMcpServer({ name, version }) {
|
|
|
181
270
|
registerAttachmentTools(server);
|
|
182
271
|
registerSignedDocTools(server);
|
|
183
272
|
registerFeedbackTools(server);
|
|
273
|
+
registerMemoryTools(server);
|
|
184
274
|
|
|
185
275
|
return server;
|
|
186
276
|
}
|