rootr-cli 0.1.0 → 0.2.1

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/lib/client.js CHANGED
@@ -465,4 +465,40 @@ export class RootrClient {
465
465
  method: 'DELETE',
466
466
  });
467
467
  }
468
+
469
+ // ---- Databases (rows) -------------------------------------------------
470
+
471
+ /** Database definition: { id, name, icon, path, properties[], views[], rowCount }. */
472
+ async getDatabase(databaseId) {
473
+ return this.request(`/databases/${encodeURIComponent(databaseId)}`);
474
+ }
475
+
476
+ /** All rows of a database, in position order. Returns the raw rows array. */
477
+ async listRows(databaseId) {
478
+ const data = await this.request(`/databases/${encodeURIComponent(databaseId)}/rows`);
479
+ if (Array.isArray(data)) return data;
480
+ if (data && Array.isArray(data.rows)) return data.rows;
481
+ return [];
482
+ }
483
+
484
+ async createRow(databaseId, { values, position } = {}) {
485
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/rows`, {
486
+ method: 'POST',
487
+ body: { values, position },
488
+ });
489
+ }
490
+
491
+ async updateRow(databaseId, rowId, { values, position } = {}) {
492
+ return this.request(
493
+ `/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}`,
494
+ { method: 'PATCH', body: { values, position } },
495
+ );
496
+ }
497
+
498
+ async deleteRow(databaseId, rowId) {
499
+ return this.request(
500
+ `/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}`,
501
+ { method: 'DELETE' },
502
+ );
503
+ }
468
504
  }
@@ -0,0 +1,163 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, errorResult, makeClient } from './shared.js';
4
+
5
+ /**
6
+ * Resolve a `{propertyNameOrId: value}` map to `{propertyId: value}` using the
7
+ * database schema, so agents can address columns by their human name OR their
8
+ * id. `null` is preserved (clear marker for PATCH). Throws on an unknown key so
9
+ * the model is told to read the schema first.
10
+ */
11
+ function resolveValueKeys(properties, values) {
12
+ const byId = new Map(properties.map((p) => [p.id, p]));
13
+ const byName = new Map(properties.map((p) => [p.name.toLowerCase(), p]));
14
+ const out = {};
15
+ for (const [key, v] of Object.entries(values || {})) {
16
+ const prop = byId.get(key) ?? byName.get(String(key).toLowerCase());
17
+ if (!prop) {
18
+ throw new Error(`Unknown property "${key}". Call rootr_read_database first to see the column ids/names.`);
19
+ }
20
+ out[prop.id] = v;
21
+ }
22
+ return out;
23
+ }
24
+
25
+ /**
26
+ * Database (Notion-style table/board/kanban) row tools. The REST layer already
27
+ * exposes GET /databases/:id (schema), GET/POST /databases/:id/rows and
28
+ * PATCH/DELETE /databases/:id/rows/:rowId — these tools wrap it so agents can
29
+ * read the schema, then create/move/edit/delete rows (e.g. change a select
30
+ * "status" column = move a kanban card between columns).
31
+ */
32
+ export function registerDatabaseTools(server) {
33
+ server.registerTool(
34
+ 'rootr_read_database',
35
+ {
36
+ title: 'Read a database schema and rows',
37
+ description:
38
+ 'Read a Rootr (루터) DATABASE node — its schema (properties with ids/types and select options) plus all rows ' +
39
+ '(each with its rowId and values). ALWAYS call this before rootr_add_row / rootr_update_row so you know the exact ' +
40
+ 'property ids/names and, for board/kanban views, which select column holds the status. Only works on DATABASE nodes.',
41
+ inputSchema: {
42
+ databaseId: z.string().describe('DATABASE node id'),
43
+ },
44
+ },
45
+ async ({ databaseId }) => {
46
+ try {
47
+ const client = makeClient();
48
+ const [database, rows] = await Promise.all([
49
+ client.getDatabase(databaseId),
50
+ client.listRows(databaseId),
51
+ ]);
52
+ return jsonResult({ database, rows });
53
+ } catch (err) {
54
+ return errorResult(err);
55
+ }
56
+ },
57
+ );
58
+
59
+ server.registerTool(
60
+ 'rootr_list_rows',
61
+ {
62
+ title: 'List database rows',
63
+ description:
64
+ 'List the rows of a Rootr (루터) DATABASE (id + values + position), without the schema. ' +
65
+ 'Use rootr_read_database instead when you also need the property/column definitions.',
66
+ inputSchema: {
67
+ databaseId: z.string().describe('DATABASE node id'),
68
+ },
69
+ },
70
+ async ({ databaseId }) => {
71
+ try {
72
+ const client = makeClient();
73
+ const rows = await client.listRows(databaseId);
74
+ return jsonResult({ rows });
75
+ } catch (err) {
76
+ return errorResult(err);
77
+ }
78
+ },
79
+ );
80
+
81
+ server.registerTool(
82
+ 'rootr_add_row',
83
+ {
84
+ title: 'Add a database row',
85
+ description:
86
+ 'Append a row to a Rootr (루터) DATABASE. `values` maps property NAME (or id) → value. ' +
87
+ 'Value shapes by type: title/text/url/select/person → string; number → number; multi_select → string[]; ' +
88
+ 'checkbox → boolean; date → { start: "YYYY-MM-DD", end? }. A select value is the OPTION NAME. ' +
89
+ 'Call rootr_read_database first to learn the columns. Optionally set `position` to insert at an index.',
90
+ inputSchema: {
91
+ databaseId: z.string().describe('DATABASE node id'),
92
+ values: z.record(z.unknown()).describe('{ propertyNameOrId: value } for the new row'),
93
+ position: z.number().optional().describe('0-based insert index; appended at the end if omitted'),
94
+ },
95
+ },
96
+ async ({ databaseId, values, position }) => {
97
+ try {
98
+ const client = makeClient();
99
+ const database = await client.getDatabase(databaseId);
100
+ const resolved = resolveValueKeys(database.properties || [], values);
101
+ const row = await client.createRow(databaseId, { values: resolved, position });
102
+ return jsonResult(row);
103
+ } catch (err) {
104
+ return errorResult(err);
105
+ }
106
+ },
107
+ );
108
+
109
+ server.registerTool(
110
+ 'rootr_update_row',
111
+ {
112
+ title: 'Update a database row',
113
+ description:
114
+ 'Merge-patch ONE row of a Rootr (루터) DATABASE by rowId (get rowIds from rootr_read_database). ' +
115
+ '`values` maps property NAME (or id) → new value; only the passed keys change, `null` clears a key, omitted keys ' +
116
+ 'are left unchanged. To MOVE a kanban card to another column, set its select/status property to the new option name. ' +
117
+ 'Optionally set `position` to reorder the row.',
118
+ inputSchema: {
119
+ databaseId: z.string().describe('DATABASE node id'),
120
+ rowId: z.string().describe('Row id (must belong to the database)'),
121
+ values: z.record(z.unknown()).optional().describe('{ propertyNameOrId: newValue | null } partial patch'),
122
+ position: z.number().optional().describe('New 0-based position for the row'),
123
+ },
124
+ },
125
+ async ({ databaseId, rowId, values, position }) => {
126
+ try {
127
+ const client = makeClient();
128
+ let resolved;
129
+ if (values !== undefined) {
130
+ const database = await client.getDatabase(databaseId);
131
+ resolved = resolveValueKeys(database.properties || [], values);
132
+ }
133
+ const row = await client.updateRow(databaseId, rowId, { values: resolved, position });
134
+ return jsonResult(row);
135
+ } catch (err) {
136
+ return errorResult(err);
137
+ }
138
+ },
139
+ );
140
+
141
+ server.registerTool(
142
+ 'rootr_delete_row',
143
+ {
144
+ title: 'Delete a database row',
145
+ description:
146
+ 'Delete ONE row of a Rootr (루터) DATABASE by rowId (its linked page, if any, is moved to the trash). ' +
147
+ 'Verify the row via rootr_read_database first — do not guess rowIds.',
148
+ inputSchema: {
149
+ databaseId: z.string().describe('DATABASE node id'),
150
+ rowId: z.string().describe('Row id to delete (must belong to the database)'),
151
+ },
152
+ },
153
+ async ({ databaseId, rowId }) => {
154
+ try {
155
+ const client = makeClient();
156
+ await client.deleteRow(databaseId, rowId);
157
+ return jsonResult({ deleted: true, rowId });
158
+ } catch (err) {
159
+ return errorResult(err);
160
+ }
161
+ },
162
+ );
163
+ }
@@ -0,0 +1,348 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
4
+
5
+ /**
6
+ * Authoring guide, repeated (shortened) across every tool description so an
7
+ * agent that only sees ONE of these tools still gets the rules: assertion
8
+ * titles, text-spine-carries-meaning, 16:9 canvas, kind for slide role.
9
+ */
10
+ const AUTHORING_GUIDE =
11
+ 'Authoring guide: give each slide ONE assertion-style title (a claim, e.g. "Latency dropped 40% after the cache fix" — ' +
12
+ 'not a topic label like "Latency"), plus blocks[] (bullet-like {id, heading?, body?, icon?} items) and, where useful, ' +
13
+ 'a diagram ({type:"mermaid", code}) or images. Canvas is 16:9 (1280x720). Meaning must live in TEXT — ' +
14
+ 'title/blocks[].heading/blocks[].body/notes are what auto-connects into the knowledge graph; diagrams/code/html and the ' +
15
+ 'image pixels themselves are visual-only and are NOT indexed, so never put facts ONLY in a diagram or picture.\n' +
16
+ 'Images: `image` is the single primary/background image (cover, section, full-bleed); `images[]` holds additional ' +
17
+ 'inserted images placed on the slide. Each image is {id?, src?, alt?, placement?, prompt?, x?, y?, w?, h?}. ' +
18
+ 'Set `src` to an uploaded file URL (upload via POST /v1/attachments/upload, then use /api/v1/attachments/{id}/raw), ' +
19
+ 'an https URL, or a data: URI — you cannot upload the file bytes through these MCP tools, only reference the resulting URL. ' +
20
+ 'To get a REAL image, call rootr_generate_image with an English prompt (say "no text"), then put the returned `url` ' +
21
+ 'into the slide image\'s `src`; rootr_remove_image_background cuts out an image\'s background. ' +
22
+ '`placement` is a layout hint ("full"|"right"|"top"|...); x/y/w/h give freeform PPT-style placement in 1280x720 canvas ' +
23
+ 'coords. ALWAYS add an `alt` caption to every image — alt text is part of the graph text spine, so a captioned image ' +
24
+ 'connects into the knowledge graph while an uncaptioned one does not.\n' +
25
+ 'Use kind to mark each slide\'s role: "cover" (deck title slide), "section" (chapter divider), "content" (body slide, ' +
26
+ 'the default), "closing" (last slide / call to action).\n' +
27
+ 'Page numbers: "content" and "section" slides automatically show a page number that follows the slide order (it ' +
28
+ 're-numbers itself when slides are reordered), so you do NOT set it yourself. "cover"/"closing" have none by design.';
29
+
30
+ /** Fill in slide/block ids that were omitted, so agents don't have to invent unique ids by hand. */
31
+ function ensureSlideIds(slides) {
32
+ if (!Array.isArray(slides)) return slides;
33
+ return slides.map((s, i) => {
34
+ const slide = { ...s, id: s.id || `SLD-${String(i + 1).padStart(3, '0')}` };
35
+ if (Array.isArray(slide.blocks)) {
36
+ slide.blocks = slide.blocks.map((b, bi) => ({ ...b, id: b.id || `${slide.id}-B${bi + 1}` }));
37
+ }
38
+ return slide;
39
+ });
40
+ }
41
+
42
+ const imageShape = z
43
+ .object({
44
+ id: z.string().optional().describe('Stable image id (for multi-image slides). Auto-generated if omitted.'),
45
+ src: z
46
+ .string()
47
+ .optional()
48
+ .describe(
49
+ 'Image URL: an uploaded file (/api/v1/attachments/{id}/raw — upload first via POST /v1/attachments/upload), ' +
50
+ 'an https URL, or a data: URI. Omit and set `prompt` instead to have it AI-generated later.',
51
+ ),
52
+ alt: z
53
+ .string()
54
+ .optional()
55
+ .describe('Caption / alt text. Part of the knowledge-graph text spine — ALWAYS set this so the image is indexed.'),
56
+ prompt: z.string().optional().describe('English AI-generation prompt, no text baked into the image (used when src is absent)'),
57
+ placement: z.string().optional().describe('Layout hint: "full" | "right" | "top" | ...'),
58
+ x: z.number().optional().describe('Freeform placement X in 1280x720 canvas coords'),
59
+ y: z.number().optional().describe('Freeform placement Y in 1280x720 canvas coords'),
60
+ w: z.number().optional().describe('Freeform placement width in canvas coords'),
61
+ h: z.number().optional().describe('Freeform placement height in canvas coords'),
62
+ })
63
+ .describe('A slide image. Set src (uploaded/https/data: URI) or prompt (AI-generate); always add an alt caption.');
64
+
65
+ const slideShape = z
66
+ .object({
67
+ id: z.string().optional().describe('Unique slide id, e.g. "SLD-001". Auto-generated if omitted.'),
68
+ kind: z.string().optional().describe('cover | section | content | closing'),
69
+ layout: z.string().optional().describe('Layout hint, e.g. "2-col" | "kpi-grid" | "timeline"'),
70
+ title: z.string().optional().describe('Assertion-style title (the claim this slide makes) — text spine'),
71
+ blocks: z
72
+ .array(
73
+ z.object({
74
+ id: z.string().optional().describe('Unique block id. Auto-generated if omitted.'),
75
+ heading: z.string().optional(),
76
+ body: z.string().optional(),
77
+ icon: z.string().optional().describe('Semantic hint, e.g. "shield" | "calendar" | "chip"'),
78
+ }),
79
+ )
80
+ .optional()
81
+ .describe('Bullet-like items — text spine'),
82
+ diagram: z
83
+ .object({ type: z.literal('mermaid').optional(), code: z.string() })
84
+ .optional()
85
+ .describe('Mermaid diagram; visual only, not indexed — keep the facts in title/blocks too'),
86
+ image: imageShape
87
+ .optional()
88
+ .describe('Single primary/background image (cover, section, full-bleed). See imageShape fields.'),
89
+ images: z
90
+ .array(imageShape)
91
+ .optional()
92
+ .describe('Additional inserted/uploaded images placed on the slide (up to 50). Each should carry an alt caption.'),
93
+ code: z.string().optional().describe('Code-first React slide source (max design freedom, advanced use)'),
94
+ html: z.string().optional().describe('Rendered self-contained slide HTML (usually machine-produced, not hand-authored)'),
95
+ notes: z.string().optional().describe('Speaker notes — text spine'),
96
+ })
97
+ .describe('A single slide. See tool description for the authoring guide.');
98
+
99
+ const themeShape = z
100
+ .object({
101
+ colors: z
102
+ .object({
103
+ primary: z.string().optional(),
104
+ secondary: z.string().optional(),
105
+ accent: z.string().optional(),
106
+ bg: z.string().optional(),
107
+ text: z.string().optional(),
108
+ muted: z.string().optional(),
109
+ })
110
+ .optional(),
111
+ fonts: z.object({ heading: z.string().optional(), body: z.string().optional() }).optional(),
112
+ keyVisualStyle: z.string().optional(),
113
+ })
114
+ .optional()
115
+ .describe('Deck theme; missing keys fall back to sane defaults');
116
+
117
+ /** PRESENTATION (slide deck) node tools: create/read/update deck, append + reorder + patch slides. */
118
+ export function registerPresentationTools(server) {
119
+ server.registerTool(
120
+ 'rootr_create_presentation',
121
+ {
122
+ title: 'Create a Rootr presentation (slide deck)',
123
+ description:
124
+ 'Create a new PRESENTATION node (a slide deck) in a Rootr (루터) workspace, optionally seeded with an ' +
125
+ 'initial theme and slides. ' +
126
+ AUTHORING_GUIDE,
127
+ inputSchema: {
128
+ name: z.string().optional().describe('Deck name/title'),
129
+ parentId: z.string().optional().describe('Parent folder/node id to create the deck under'),
130
+ theme: themeShape,
131
+ slides: z.array(slideShape).optional().describe('Initial slides, in order'),
132
+ config: z.record(z.unknown()).optional().describe('Free-form deck config/settings object'),
133
+ icon: z.string().optional().describe('Emoji icon for the deck node'),
134
+ workspace: z
135
+ .string()
136
+ .optional()
137
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
138
+ },
139
+ },
140
+ async ({ name, parentId, theme, slides, config, icon, workspace }) => {
141
+ try {
142
+ const ws = requireWorkspace(workspace);
143
+ const client = makeClient(ws);
144
+ const result = await client.createPresentation(ws, {
145
+ name,
146
+ parentId,
147
+ theme,
148
+ slides: ensureSlideIds(slides),
149
+ config,
150
+ icon,
151
+ });
152
+ return jsonResult(result);
153
+ } catch (err) {
154
+ return errorResult(err);
155
+ }
156
+ },
157
+ );
158
+
159
+ server.registerTool(
160
+ 'rootr_read_presentation',
161
+ {
162
+ title: 'Read a Rootr presentation',
163
+ description:
164
+ 'Read a Rootr (루터) PRESENTATION node — its theme and full slides array. Read this before ' +
165
+ 'rootr_update_presentation / rootr_append_presentation_slides / rootr_reorder_presentation_slides so you ' +
166
+ 'know the current slide ids and content.',
167
+ inputSchema: {
168
+ presentationId: z.string().describe('PRESENTATION node id'),
169
+ },
170
+ },
171
+ async ({ presentationId }) => {
172
+ try {
173
+ const client = makeClient();
174
+ const result = await client.getPresentation(presentationId);
175
+ return jsonResult(result);
176
+ } catch (err) {
177
+ return errorResult(err);
178
+ }
179
+ },
180
+ );
181
+
182
+ server.registerTool(
183
+ 'rootr_update_presentation',
184
+ {
185
+ title: 'Update a Rootr presentation (theme/slides/config)',
186
+ description:
187
+ 'Merge-patch a Rootr (루터) PRESENTATION: update theme and/or config, or REPLACE THE WHOLE slides array ' +
188
+ '(if you pass `slides`, it replaces every existing slide — call rootr_read_presentation first and resend ' +
189
+ 'slides you want to keep, or use rootr_append_presentation_slides / rootr_update_presentation_slide instead ' +
190
+ 'for incremental changes). ' +
191
+ AUTHORING_GUIDE,
192
+ inputSchema: {
193
+ presentationId: z.string().describe('PRESENTATION node id'),
194
+ theme: themeShape,
195
+ slides: z
196
+ .array(slideShape)
197
+ .optional()
198
+ .describe('COMPLETE slides array to set — omitted existing slides will be dropped'),
199
+ config: z.record(z.unknown()).optional().describe('Free-form deck config/settings object'),
200
+ icon: z.string().optional().describe('Emoji icon for the deck node'),
201
+ },
202
+ },
203
+ async ({ presentationId, theme, slides, config, icon }) => {
204
+ try {
205
+ const client = makeClient();
206
+ const result = await client.updatePresentation(presentationId, {
207
+ theme,
208
+ slides: ensureSlideIds(slides),
209
+ config,
210
+ icon,
211
+ });
212
+ return jsonResult(result);
213
+ } catch (err) {
214
+ return errorResult(err);
215
+ }
216
+ },
217
+ );
218
+
219
+ server.registerTool(
220
+ 'rootr_append_presentation_slides',
221
+ {
222
+ title: 'Append slides to a Rootr presentation (preferred way to add slides)',
223
+ description:
224
+ 'Append one or more slides to the END of a Rootr (루터) PRESENTATION, without touching existing slides. ' +
225
+ 'PREFER this over rootr_update_presentation when you are only adding new slides. ' +
226
+ AUTHORING_GUIDE,
227
+ inputSchema: {
228
+ presentationId: z.string().describe('PRESENTATION node id'),
229
+ slides: z.array(slideShape).describe('Slides to append, in order'),
230
+ },
231
+ },
232
+ async ({ presentationId, slides }) => {
233
+ try {
234
+ const client = makeClient();
235
+ const result = await client.appendPresentationSlides(presentationId, ensureSlideIds(slides));
236
+ return jsonResult(result);
237
+ } catch (err) {
238
+ return errorResult(err);
239
+ }
240
+ },
241
+ );
242
+
243
+ server.registerTool(
244
+ 'rootr_update_presentation_slide',
245
+ {
246
+ title: 'Update ONE slide of a Rootr presentation',
247
+ description:
248
+ 'Merge-patch a single slide by slideId, without touching the rest of the deck. Good for a precise, ' +
249
+ 'targeted edit (e.g. rewrite one slide\'s title/blocks); use rootr_append_presentation_slides instead when ' +
250
+ 'you are only adding new slides. Call rootr_read_presentation first to get the exact slideId. ' +
251
+ AUTHORING_GUIDE,
252
+ inputSchema: {
253
+ presentationId: z.string().describe('PRESENTATION node id'),
254
+ slideId: z.string().describe('Slide id to update (must belong to the deck)'),
255
+ slide: slideShape.describe('Partial slide fields to merge into the existing slide'),
256
+ },
257
+ },
258
+ async ({ presentationId, slideId, slide }) => {
259
+ try {
260
+ const client = makeClient();
261
+ const result = await client.updatePresentationSlide(presentationId, slideId, slide);
262
+ return jsonResult(result);
263
+ } catch (err) {
264
+ return errorResult(err);
265
+ }
266
+ },
267
+ );
268
+
269
+ server.registerTool(
270
+ 'rootr_reorder_presentation_slides',
271
+ {
272
+ title: 'Reorder the slides of a Rootr presentation',
273
+ description:
274
+ 'Reorder a Rootr (루터) PRESENTATION\'s slides. `order` must be the FULL list of the deck\'s slide ids, ' +
275
+ 'in the new desired order — call rootr_read_presentation first to get the current ids.',
276
+ inputSchema: {
277
+ presentationId: z.string().describe('PRESENTATION node id'),
278
+ order: z.array(z.string()).describe('Complete ordered array of slide ids'),
279
+ },
280
+ },
281
+ async ({ presentationId, order }) => {
282
+ try {
283
+ const client = makeClient();
284
+ const result = await client.reorderPresentationSlides(presentationId, order);
285
+ return jsonResult(result);
286
+ } catch (err) {
287
+ return errorResult(err);
288
+ }
289
+ },
290
+ );
291
+
292
+ server.registerTool(
293
+ 'rootr_generate_image',
294
+ {
295
+ title: 'Generate an image from a text prompt (for a slide)',
296
+ description:
297
+ 'Generate an image from a text prompt with AI (Gemini) and store it as a workspace attachment. Use this ' +
298
+ 'when building a Rootr (루터) presentation so slides get real, on-brand visuals instead of empty image ' +
299
+ 'placeholders — generate the image, then put the returned `url` into a slide image\'s `src` via ' +
300
+ 'rootr_update_presentation_slide (image or images[]). Returns { id, url, filename, mimeType, size }.\n' +
301
+ 'Prompt tips: describe a clean, flat, presentation-ready illustration; specify colors to match the deck ' +
302
+ 'theme; and say "no text" — baked-in text is not indexed and often renders wrong (put the meaning in the ' +
303
+ 'slide title/blocks instead, and always give the image an `alt` caption). Consumes AI credits.',
304
+ inputSchema: {
305
+ prompt: z.string().describe('English image description; ask for "no text" in the image'),
306
+ nodeId: z.string().optional().describe('Presentation/node id to attach the image to (permission-checked)'),
307
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
308
+ },
309
+ },
310
+ async ({ prompt, nodeId, workspace }) => {
311
+ try {
312
+ const client = makeClient();
313
+ const ws = requireWorkspace(workspace);
314
+ const result = await client.generateImage(ws, { prompt, nodeId });
315
+ return jsonResult(result);
316
+ } catch (err) {
317
+ return errorResult(err);
318
+ }
319
+ },
320
+ );
321
+
322
+ server.registerTool(
323
+ 'rootr_remove_image_background',
324
+ {
325
+ title: 'Remove the background of an image',
326
+ description:
327
+ 'Remove the background of an existing Rootr (루터) workspace attachment (e.g. a generated or uploaded image) ' +
328
+ 'and store the result as a new transparent PNG. Pass the source attachment id; returns { id, url, ... } for ' +
329
+ 'the cut-out image, which you can then place on a slide. Useful for product shots / logos / subjects that ' +
330
+ 'should sit on the slide without a boxed background.',
331
+ inputSchema: {
332
+ attachmentId: z.string().describe('Source attachment id (from rootr_generate_image or an upload)'),
333
+ nodeId: z.string().optional().describe('Presentation/node id to attach the result to (permission-checked)'),
334
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
335
+ },
336
+ },
337
+ async ({ attachmentId, nodeId, workspace }) => {
338
+ try {
339
+ const client = makeClient();
340
+ const ws = requireWorkspace(workspace);
341
+ const result = await client.removeImageBackground(ws, { attachmentId, nodeId });
342
+ return jsonResult(result);
343
+ } catch (err) {
344
+ return errorResult(err);
345
+ }
346
+ },
347
+ );
348
+ }
@@ -2,6 +2,26 @@ import { z } from 'zod';
2
2
 
3
3
  import { textResult, jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
4
4
 
5
+ /**
6
+ * Scaffold `tree` argument schema. Declared as an ARRAY of node objects so MCP
7
+ * clients serialize the value as real JSON. A bare `z.any()` emits an empty
8
+ * JSON Schema (no type), which some clients send as a stringified blob — the
9
+ * scaffold API then rejects it with "tree must be an array". The `.or(z.string())`
10
+ * branch plus parseTree() keeps any client that still stringifies working.
11
+ * Mirrors the typed-array pattern already used in logs.js.
12
+ */
13
+ const treeArg = (desc) => z.array(z.record(z.any())).or(z.string()).describe(desc);
14
+
15
+ /** Accept either a real array (typed clients) or a JSON string (clients that stringify structured args). */
16
+ function parseTree(tree) {
17
+ if (typeof tree !== 'string') return tree;
18
+ try {
19
+ return JSON.parse(tree);
20
+ } catch {
21
+ throw new Error('tree must be a JSON array of node objects (received an unparseable string)');
22
+ }
23
+ }
24
+
5
25
  /** Workspace listing and scaffolding tools: rootr_workspaces, rootr_scaffold_plan, rootr_create_workspace, rootr_scaffold_apply. */
6
26
  export function registerWorkspaceTools(server) {
7
27
  server.registerTool(
@@ -70,13 +90,13 @@ export function registerWorkspaceTools(server) {
70
90
  inputSchema: {
71
91
  name: z.string().describe('Name for the new workspace'),
72
92
  intent: z.string().optional().describe('Original natural-language intent, for reference/regeneration'),
73
- tree: z.any().describe('Scaffold tree to populate the new workspace with (see llms.txt tree schema)'),
93
+ tree: treeArg('Scaffold tree — an ARRAY of node objects to populate the new workspace with (see llms.txt tree schema)'),
74
94
  },
75
95
  },
76
96
  async ({ name, intent, tree }) => {
77
97
  try {
78
98
  const client = makeClient();
79
- const result = await client.createWorkspaceScaffold({ name, intent, tree });
99
+ const result = await client.createWorkspaceScaffold({ name, intent, tree: parseTree(tree) });
80
100
  return jsonResult(result);
81
101
  } catch (err) {
82
102
  return errorResult(err);
@@ -96,7 +116,7 @@ export function registerWorkspaceTools(server) {
96
116
  'AFTER creating: if your runtime can receive HTTP callbacks, offer a webhook on the new subtree ' +
97
117
  '(rootr_create_webhook with scopeNodeId) so changes flow back to you — skip your own actorId.',
98
118
  inputSchema: {
99
- tree: z.any().describe('Scaffold tree to create (see llms.txt tree schema)'),
119
+ tree: treeArg('Scaffold tree — an ARRAY of node objects to create (see llms.txt tree schema)'),
100
120
  rootPath: z.string().optional().describe('Existing path to root the new tree under, e.g. "/projects"'),
101
121
  workspace: z
102
122
  .string()
@@ -108,7 +128,7 @@ export function registerWorkspaceTools(server) {
108
128
  try {
109
129
  const ws = requireWorkspace(workspace);
110
130
  const client = makeClient(ws);
111
- const result = await client.scaffoldApply(ws, { rootPath, tree });
131
+ const result = await client.scaffoldApply(ws, { rootPath, tree: parseTree(tree) });
112
132
  return jsonResult(result);
113
133
  } catch (err) {
114
134
  return errorResult(err);
package/lib/mcp.js CHANGED
@@ -6,6 +6,7 @@ import { registerWorkspaceTools } from './mcp-tools/workspaces.js';
6
6
  import { registerLogTools } from './mcp-tools/logs.js';
7
7
  import { registerAskTools } from './mcp-tools/ask.js';
8
8
  import { registerIssueTools } from './mcp-tools/issues.js';
9
+ import { registerDatabaseTools } from './mcp-tools/databases.js';
9
10
  import { registerWebhookTools } from './mcp-tools/webhooks.js';
10
11
  import { registerMiscTools } from './mcp-tools/misc.js';
11
12
 
@@ -20,7 +21,9 @@ export async function startMcpServer({ name, version }) {
20
21
  'rootr_edit for targeted changes; use rootr_write only for full rewrites. Beyond documents, this ' +
21
22
  'server also covers workspace scaffolding (planning/creating/applying document trees), typed LOG ' +
22
23
  'datastores (structured entries + stats), GraphRAG root-cause Q&A (rootr_ask), GitHub-style issue ' +
23
- 'trackers, and webhooks.\n\n' +
24
+ 'trackers, DATABASE rows (read schema + rows, add/update/delete rows — e.g. move a kanban card by ' +
25
+ 'changing its status column, via rootr_read_database / rootr_add_row / rootr_update_row / rootr_delete_row), ' +
26
+ 'and webhooks.\n\n' +
24
27
  'Node types — pick the block that fits, never fake everything with markdown files: ' +
25
28
  'DOCUMENT = free-form markdown narrative (overviews, specs, decision logs); ' +
26
29
  'DATABASE = typed columns + views (table/board/calendar/timeline) for anything trackable — tasks, requirements, risks, milestones; ' +
@@ -42,6 +45,7 @@ export async function startMcpServer({ name, version }) {
42
45
  registerLogTools(server);
43
46
  registerAskTools(server);
44
47
  registerIssueTools(server);
48
+ registerDatabaseTools(server);
45
49
  registerWebhookTools(server);
46
50
  registerMiscTools(server);
47
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Official Rootr CLI + MCP server — read, write, edit and search Rootr documents from the shell or from an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,17 +14,7 @@
14
14
  "lib",
15
15
  "README.md"
16
16
  ],
17
- "keywords": [
18
- "rootr",
19
- "mcp",
20
- "model-context-protocol",
21
- "ai-agent",
22
- "knowledge-base",
23
- "markdown",
24
- "cli"
25
- ],
26
- "homepage": "https://rootr.io",
27
- "license": "MIT",
17
+ "license": "UNLICENSED",
28
18
  "dependencies": {
29
19
  "@modelcontextprotocol/sdk": "^1.0.0",
30
20
  "zod": "^3.23.8"