rootr-cli 0.18.0 → 0.19.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.ko.md +49 -2
- package/README.md +46 -4
- package/bin/rootr.js +481 -11
- package/lib/client.js +183 -24
- package/lib/credentials.js +20 -2
- package/lib/mcp-tools/ask.js +6 -1
- package/lib/mcp-tools/attachments.js +31 -11
- package/lib/mcp-tools/booking.js +403 -0
- package/lib/mcp-tools/databases.js +230 -9
- package/lib/mcp-tools/documents.js +183 -16
- package/lib/mcp-tools/forms.js +3 -1
- package/lib/mcp-tools/logs.js +1 -1
- package/lib/mcp-tools/misc.js +9 -3
- package/lib/mcp-tools/pages.js +31 -7
- package/lib/mcp-tools/presentations.js +36 -12
- package/lib/mcp-tools/public.js +4 -1
- package/lib/mcp-tools/recordings.js +39 -15
- package/lib/mcp-tools/shared.js +28 -0
- package/lib/mcp-tools/workspaces.js +155 -77
- package/lib/mcp.js +3 -0
- package/lib/resolve.js +43 -1
- package/package.json +1 -1
package/lib/mcp-tools/misc.js
CHANGED
|
@@ -147,13 +147,16 @@ export function registerMiscTools(server) {
|
|
|
147
147
|
'permanent delete. Use rootr_read/rootr_list first to confirm you have the right target.',
|
|
148
148
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
149
149
|
inputSchema: {
|
|
150
|
-
target: z.string().describe('Document/folder path (starting with "/") or node id'),
|
|
150
|
+
target: z.string().optional().describe('Document/folder path (starting with "/") or node id'),
|
|
151
|
+
nodeId: z.string().optional().describe('Alias for `target` — node id of the item to delete'),
|
|
151
152
|
},
|
|
152
153
|
},
|
|
153
|
-
async ({ target }) => {
|
|
154
|
+
async ({ target, nodeId }) => {
|
|
154
155
|
try {
|
|
156
|
+
const want = target ?? nodeId;
|
|
157
|
+
if (!want) throw new Error('target (or nodeId) is required');
|
|
155
158
|
const client = makeClient();
|
|
156
|
-
const resolved = await resolveTarget(client,
|
|
159
|
+
const resolved = await resolveTarget(client, want);
|
|
157
160
|
const id = requireResolved(resolved);
|
|
158
161
|
await client.deleteNode(id);
|
|
159
162
|
return jsonResult({
|
|
@@ -203,6 +206,9 @@ export function registerMiscTools(server) {
|
|
|
203
206
|
description:
|
|
204
207
|
'Read the READ-ACK state of a Rootr (루터) node — who must read it, the due date, who has read or ' +
|
|
205
208
|
'acknowledged, and (for a DATABASE or FOLDER) how far through the documents inside each person is. ' +
|
|
209
|
+
'Each target has `completed`; on a DATABASE/FOLDER a person is done once they have read every document THEY can open ' +
|
|
210
|
+
'(`readCount`/`readTotal` per person — on an "own rows only" table that is their own row pages plus shared documents). ' +
|
|
211
|
+
'If you cannot see every document inside, other people carry only `completed` (no counts). ' +
|
|
206
212
|
'Use it to answer "누가 아직 안 읽었나" for a 규정/공지/교육자료. If the node itself has no requirement ' +
|
|
207
213
|
'but an ancestor does, `ancestorRequirementId` names the node that carries it.',
|
|
208
214
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
package/lib/mcp-tools/pages.js
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
*/
|
|
26
26
|
const BLOCK_GUIDE =
|
|
27
27
|
'A page is `blocks[]` (ordered, max 200) + `config`. Each block is {id?, type, props}. ' +
|
|
28
|
-
'You assemble a page from these
|
|
28
|
+
'You assemble a page from these block types:\n' +
|
|
29
29
|
'· hero {title, subtitle?, align?:"left"|"center", background?:{kind:"color",color}|{kind:"image",attachmentId}, button?:{label,target}} — the opening banner\n' +
|
|
30
30
|
'· text {body (markdown), title?} — prose section; markdown is sanitized when rendered\n' +
|
|
31
31
|
'· features {columns?:1|2|3, items:[{title, body, icon?(emoji)}]} — "what it does" grid\n' +
|
|
@@ -38,13 +38,31 @@ const BLOCK_GUIDE =
|
|
|
38
38
|
'· footer {links:[{label, target}], contact?, copyright?}\n' +
|
|
39
39
|
'· liveStats {cards:[{label, icon?, source:{nodeId(DATABASE/LOG node), agg:"count"|"sum"|"avg"|"min"|"max", column?}}]} — numbers read LIVE from a table\n' +
|
|
40
40
|
'· liveTable {source:{nodeId}, columns?:[name], rowLimit?(1-200), orderBy?:{column, dir:"asc"|"desc"}}\n' +
|
|
41
|
-
'· liveChart {source:{nodeId}, chart
|
|
41
|
+
'· liveChart {source:{nodeId, sheet?, range?}, chart, labelColumn, valueColumn?, agg?, …} — a LIVE chart (46 kinds, see CHARTS below)\n' +
|
|
42
42
|
'· form {formNodeId(FORM node), headline?, submitLabel?} — visitors submit right on the page\n' +
|
|
43
43
|
'· custom {html, height?(40-4000, default 260)} — free HTML/CSS, rendered inside a locked-down sandboxed iframe ' +
|
|
44
44
|
'(no scripts, no same-origin, no network). Use it for a look the other blocks cannot make. Page theme colors are ' +
|
|
45
45
|
'available inside as var(--pg-bg|text|muted|faint|card|line|accent|on-accent) and var(--pg-font). Scripts and ' +
|
|
46
46
|
'on*= handlers are stripped on save — do not put behaviour here, only markup and CSS.\n' +
|
|
47
|
-
'
|
|
47
|
+
'LAYOUT: any block takes `width:"full"|"twoThirds"|"half"|"third"` — neighbouring blocks sit side by side (phones stack them). ' +
|
|
48
|
+
'That is how a page becomes a DASHBOARD: a row of `chart:"kpi"` blocks at width "third", then charts at width "half".\n' +
|
|
49
|
+
'CHARTS (liveChart): chart ∈ bar|barH|stackedBar|stackedBarH|percentBar|lollipop|dumbbell|bullet|waterfall|pareto|radar|polarBar|line|step|area|stackedArea|percentArea|combo|slope|stream|calendar|pie|donut|halfDonut|rose|funnel|treemap|sunburst|waffle|histogram|boxplot|heatmap|scatter|bubble|sankey|chord|network|tree|kpi|gauge|progress|ring|candlestick|gantt|wordCloud|pivot. ' +
|
|
50
|
+
'source = a DATABASE, SPREADSHEET or LOG node (sheet: {nodeId, sheet?(name), range?:"A1:F200"} — first row = column names, formula results are read). ' +
|
|
51
|
+
'Category charts: labelColumn (dates are bucketed: dateBucket day|week|month|quarter|year|weekday|hour, empty periods filled with 0) + ' +
|
|
52
|
+
'valueColumn & agg (count|sum|avg|min|max|median|distinct — valueColumn is optional for count/distinct, which just count the matching rows per label) OR values:[{column?, agg, label?, as?:"bar"|"line"|"area", axis?:"left"|"right"}] (several series, combo/dual axis) ' +
|
|
53
|
+
'OR seriesColumn (break down / stack by another column; heatmap, stream and pivot need it). sort auto|value-desc|value-asc|label-asc|label-desc, limit (+ others:true folds the rest into "Other"), cumulative:true. ' +
|
|
54
|
+
'kpi|gauge|progress|ring: valueColumn+agg, optional dateColumn + period (today|7d|30d|90d|week|month|quarter|year) → value vs the previous same-length period + sparkline; goal or goalColumn. ' +
|
|
55
|
+
'scatter|bubble: xColumn, yColumn, sizeColumn?, seriesColumn?. histogram|boxplot: valueColumn (+labelColumn groups for boxplot), bins?. ' +
|
|
56
|
+
'sankey|chord|network: labelColumn (from) → targetColumn (to), value = agg. treemap|sunburst|tree: labelColumn + levels:[col,…]. ' +
|
|
57
|
+
'candlestick: labelColumn + open/high/low/closeColumn. gantt: labelColumn, startColumn, endColumn?. bullet: values[0]=actual + goalColumn (or goal). wordCloud: labelColumn (+splitWords:true for free text). ' +
|
|
58
|
+
'filters:[{column, op: is|is_not|contains|not_contains|gt|gte|lt|lte|empty|not_empty|last_days, value}] (values may be "@today-30"). ' +
|
|
59
|
+
'Display: title, subtitle, height(160-1200), format auto|number|compact|percent|krw|usd|decimal1|decimal2, prefix, suffix, palette theme|vivid|pastel|ocean|sunset|forest|mono, goalLabel, thresholds:[{value,color}], showLabels, showLegend, smooth. ' +
|
|
60
|
+
'rootr_read_page_live_data shows what each chart computed — a chart that could not be drawn says why (column_missing/no_rows/needs_number).\n' +
|
|
61
|
+
'LIVE/FORM BLOCKS: `source.nodeId` / `formNodeId` must be a node in the SAME workspace that you can open — write it as ' +
|
|
62
|
+
'the node id, a full "/folder/name" path, or a name that is the ONLY table/sheet/log (or form, for `formNodeId`) with ' +
|
|
63
|
+
'that name. The server resolves it and stores the id (the response shows the id). A path or name it cannot resolve — ' +
|
|
64
|
+
'missing, not readable by you, wrong node type, or a name shared by two nodes — is rejected with 400 and NOTHING is ' +
|
|
65
|
+
'saved; it is never stored as a silently empty block. `column`/`labelColumn`/' +
|
|
48
66
|
'`valueColumn`/`orderBy.column` are the column NAMES as shown in the table (not column ids). A table or form that ' +
|
|
49
67
|
'sits in a PRIVATE subtree is silently skipped on the published page — that is deliberate, never a bug to work around.\n' +
|
|
50
68
|
'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only) or {kind:"node", nodeId} ' +
|
|
@@ -65,8 +83,11 @@ export function registerPageTools(server) {
|
|
|
65
83
|
description:
|
|
66
84
|
'Create a new PAGE node in a Rootr (루터) workspace — a web page assembled from safe typed blocks, which ' +
|
|
67
85
|
'can then be published to the web at a public address (see rootr_publish_node). Use this when someone asks ' +
|
|
68
|
-
'for a
|
|
86
|
+
'for a DASHBOARD / report / KPI board over their tables, spreadsheets or logs (46 live chart kinds, numbers stay ' +
|
|
87
|
+
'current), or for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
|
|
69
88
|
'Omit `blocks` to start from a hero/text/footer skeleton. ' +
|
|
89
|
+
'In the response, `nodeId` is the page\'s node id — pass THAT as `pageId` to every later page tool ' +
|
|
90
|
+
'(and in REST paths like /v1/pages/:nodeId/live-data). The response `id` is an internal page-row id and is NOT accepted there (404). ' +
|
|
70
91
|
BLOCK_GUIDE +
|
|
71
92
|
' ' +
|
|
72
93
|
NODE_URL_HINT +
|
|
@@ -222,9 +243,12 @@ export function registerPageTools(server) {
|
|
|
222
243
|
title: 'Read what a page\'s live blocks currently show',
|
|
223
244
|
description:
|
|
224
245
|
'Read the values a Rootr (루터) PAGE\'s live blocks (liveStats/liveTable/liveChart) are showing right now — ' +
|
|
225
|
-
'the aggregates, rows and chart
|
|
226
|
-
'
|
|
227
|
-
'
|
|
246
|
+
'the aggregates, rows and chart data computed from their source tables, spreadsheets or logs. Use it after ' +
|
|
247
|
+
'building a dashboard to check every chart actually draws. Each chart carries `data` shaped by kind ' +
|
|
248
|
+
'(category {labels, series[]} · single {value, previous, goal, spark} · xy · distribution · tree · flow · ohlc · range). ' +
|
|
249
|
+
'A chart that cannot draw says why: `{shape:"empty", reason:"column_missing"|"no_rows"|"needs_number", column}` — ' +
|
|
250
|
+
'fix that column name or filter. A block missing from the result means its source was not found, is deleted, ' +
|
|
251
|
+
'or sits in a PRIVATE subtree.',
|
|
228
252
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
229
253
|
inputSchema: {
|
|
230
254
|
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
@@ -10,7 +10,7 @@ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, N
|
|
|
10
10
|
const AUTHORING_GUIDE =
|
|
11
11
|
'Authoring guide: give each slide ONE assertion-style title (a claim, e.g. "Latency dropped 40% after the cache fix" — ' +
|
|
12
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 — ' +
|
|
13
|
+
'a diagram ({type:"mermaid", code} — drawn on "content" slides) or images. Canvas is 16:9 (1280x720). Meaning must live in TEXT — ' +
|
|
14
14
|
'title/blocks[].heading/blocks[].body/notes are what auto-connects into the knowledge graph; diagrams/code/html and the ' +
|
|
15
15
|
'image pixels themselves are visual-only and are NOT indexed, so never put facts ONLY in a diagram or picture.\n' +
|
|
16
16
|
'Images: `image` is the single primary/background image (cover, section, full-bleed); `images[]` holds additional ' +
|
|
@@ -19,8 +19,10 @@ const AUTHORING_GUIDE =
|
|
|
19
19
|
'an https URL, or a data: URI — you cannot upload the file bytes through these MCP tools, only reference the resulting URL. ' +
|
|
20
20
|
'To get a REAL image, call rootr_generate_image with an English prompt (say "no text"), then put the returned `url` ' +
|
|
21
21
|
'into the slide image\'s `src`; rootr_remove_image_background cuts out an image\'s background. ' +
|
|
22
|
-
'`placement`
|
|
23
|
-
'
|
|
22
|
+
'Set `placement` on the primary `image`: "full" = background (full-bleed on cover/section/closing, faint on content); ' +
|
|
23
|
+
'"right" / "top" = beside / above the blocks, content slides only; other values are ignored and a content slide without ' +
|
|
24
|
+
'placement does not draw the image. For `images[]`, x/y/w/h give freeform PPT-style placement in 1280x720 canvas ' +
|
|
25
|
+
'coords (drawn only when both x and y are set). ALWAYS add an `alt` caption to every image — alt text is part of the graph text spine, so a captioned image ' +
|
|
24
26
|
'connects into the knowledge graph while an uncaptioned one does not.\n' +
|
|
25
27
|
'Use kind to mark each slide\'s role: "cover" (deck title slide), "section" (chapter divider), "content" (body slide, ' +
|
|
26
28
|
'the default), "closing" (last slide / call to action).\n' +
|
|
@@ -54,7 +56,13 @@ const imageShape = z
|
|
|
54
56
|
.optional()
|
|
55
57
|
.describe('Caption / alt text. Part of the knowledge-graph text spine — ALWAYS set this so the image is indexed.'),
|
|
56
58
|
prompt: z.string().optional().describe('English AI-generation prompt, no text baked into the image (used when src is absent)'),
|
|
57
|
-
placement: z
|
|
59
|
+
placement: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe(
|
|
63
|
+
'Where the primary `image` goes: "full" (background — full-bleed on cover/section/closing, faint on content) | ' +
|
|
64
|
+
'"right" | "top" (content slides only). Other values are ignored.',
|
|
65
|
+
),
|
|
58
66
|
x: z.number().optional().describe('Freeform placement X in 1280x720 canvas coords'),
|
|
59
67
|
y: z.number().optional().describe('Freeform placement Y in 1280x720 canvas coords'),
|
|
60
68
|
w: z.number().optional().describe('Freeform placement width in canvas coords'),
|
|
@@ -66,7 +74,14 @@ const slideShape = z
|
|
|
66
74
|
.object({
|
|
67
75
|
id: z.string().optional().describe('Unique slide id, e.g. "SLD-001". Auto-generated if omitted.'),
|
|
68
76
|
kind: z.string().optional().describe('cover | section | content | closing'),
|
|
69
|
-
layout: z
|
|
77
|
+
layout: z
|
|
78
|
+
.string()
|
|
79
|
+
.optional()
|
|
80
|
+
.describe(
|
|
81
|
+
'Block arrangement on content slides: "two-col" (needs 2+ blocks) | "kpi-grid" (alias "kpi"). ' +
|
|
82
|
+
'Any other value silently falls back to the default arrangement. On cover/section slides it is printed as ' +
|
|
83
|
+
'the small label above the title.',
|
|
84
|
+
),
|
|
70
85
|
title: z.string().optional().describe('Assertion-style title (the claim this slide makes) — text spine'),
|
|
71
86
|
blocks: z
|
|
72
87
|
.array(
|
|
@@ -82,7 +97,10 @@ const slideShape = z
|
|
|
82
97
|
diagram: z
|
|
83
98
|
.object({ type: z.literal('mermaid').optional(), code: z.string() })
|
|
84
99
|
.optional()
|
|
85
|
-
.describe(
|
|
100
|
+
.describe(
|
|
101
|
+
'Mermaid diagram, drawn below the blocks on "content" slides (ignored on cover/section/closing and when the ' +
|
|
102
|
+
'slide has baked html); visual only, not indexed — keep the facts in title/blocks too',
|
|
103
|
+
),
|
|
86
104
|
image: imageShape
|
|
87
105
|
.optional()
|
|
88
106
|
.describe('Single primary/background image (cover, section, full-bleed). See imageShape fields.'),
|
|
@@ -242,7 +260,12 @@ export function registerPresentationTools(server) {
|
|
|
242
260
|
try {
|
|
243
261
|
const client = makeClient();
|
|
244
262
|
presentationId = await resolveNodeId(client, presentationId);
|
|
245
|
-
|
|
263
|
+
// ISSUE-185 — do NOT mint ids here: numbering by this call's own
|
|
264
|
+
// array index always collides with the deck's existing slides
|
|
265
|
+
// (e.g. restamping "SLD-001" onto a deck that already has one).
|
|
266
|
+
// The server fills any omitted slide/block id, avoiding the deck's
|
|
267
|
+
// current ids (see PresentationsService.appendSlides).
|
|
268
|
+
const result = await client.appendPresentationSlides(presentationId, slides);
|
|
246
269
|
return jsonResult(result);
|
|
247
270
|
} catch (err) {
|
|
248
271
|
return errorResult(err);
|
|
@@ -328,8 +351,8 @@ export function registerPresentationTools(server) {
|
|
|
328
351
|
},
|
|
329
352
|
async ({ prompt, nodeId, workspace }) => {
|
|
330
353
|
try {
|
|
331
|
-
const
|
|
332
|
-
const
|
|
354
|
+
const ws = requireWorkspace(workspace); // ISSUE-61 — 워크스페이스를 먼저 정하고 그 키로(attachments.js 와 같은 결함)
|
|
355
|
+
const client = makeClient(ws);
|
|
333
356
|
nodeId = await resolveOptionalNodeId(client, nodeId);
|
|
334
357
|
const result = await client.generateImage(ws, { prompt, nodeId });
|
|
335
358
|
return jsonResult(result);
|
|
@@ -357,8 +380,8 @@ export function registerPresentationTools(server) {
|
|
|
357
380
|
},
|
|
358
381
|
async ({ attachmentId, nodeId, workspace }) => {
|
|
359
382
|
try {
|
|
360
|
-
const
|
|
361
|
-
const
|
|
383
|
+
const ws = requireWorkspace(workspace); // ISSUE-61 — 위와 같다
|
|
384
|
+
const client = makeClient(ws);
|
|
362
385
|
nodeId = await resolveOptionalNodeId(client, nodeId);
|
|
363
386
|
const result = await client.removeImageBackground(ws, { attachmentId, nodeId });
|
|
364
387
|
return jsonResult(result);
|
|
@@ -405,7 +428,8 @@ export function registerPresentationTools(server) {
|
|
|
405
428
|
description:
|
|
406
429
|
'Render a typed diagram spec into a Rootr (루터) presentation slide — architecture, workflow, sequence, ' +
|
|
407
430
|
'dataflow, or lifecycle. The drawing is baked into the slide, so it shows on screen AND in the PDF/PPTX ' +
|
|
408
|
-
'exports.
|
|
431
|
+
'exports. Prefer this over the mermaid `diagram` field when the diagram has to look designed — a plain ' +
|
|
432
|
+
'"content" slide also draws its mermaid `diagram`, but with mermaid\'s default look.\n' +
|
|
409
433
|
'Call rootr_diagram_schema first to get the exact shape for the family you want — the spec is validated ' +
|
|
410
434
|
'fail-closed and a mismatch is rejected with the offending field named and a suggested fix, which you ' +
|
|
411
435
|
'should apply and retry.\n' +
|
package/lib/mcp-tools/public.js
CHANGED
|
@@ -86,7 +86,8 @@ export function registerPublicTools(server) {
|
|
|
86
86
|
'`{"password": "..."}` locks the page (the content is not sent at all until the password is entered — it is ' +
|
|
87
87
|
'not hidden in the browser), `{"password": null}` unlocks; ' +
|
|
88
88
|
'`{"expiresAt": "2026-12-31T00:00:00Z"}` auto-closes on that date, `null` clears it; ' +
|
|
89
|
-
'`{"noIndex": true}` asks search engines to stay away
|
|
89
|
+
'`{"noIndex": true}` asks search engines to stay away (Team plan and up — a Free workspace gets 403 ' +
|
|
90
|
+
'PLAN_UPGRADE_REQUIRED; a password-locked page is always hidden and cannot be re-exposed until unlocked); ' +
|
|
90
91
|
'`{"design": {"layout": "board", "skin": "paper"}}` changes how a published TABLE looks — it does not ' +
|
|
91
92
|
'have to be a table (cards, gallery, list, board, timeline, calendar, chart, ranking). ' +
|
|
92
93
|
'For DOCUMENT nodes use rootr_set_document_public instead — documents keep their own settings (skins, ' +
|
|
@@ -129,6 +130,8 @@ export function registerPublicTools(server) {
|
|
|
129
130
|
'currently open, when it expires, whether a password is set (the password itself is never returned), whether ' +
|
|
130
131
|
'cloning is allowed, and whether the pages underneath went out with it. ' +
|
|
131
132
|
'`publishable: false` means this type cannot be published at all (FOLDER). ' +
|
|
133
|
+
'`closed: "PUBLIC_OWN_ROW_PAGE"` (with `isPublic: false` and a `closedReason`) means publishing is switched on but the ' +
|
|
134
|
+
'address is closed right now — do NOT hand that URL out; tell the user why and how to reopen it. ' +
|
|
132
135
|
'Call this before publishing so you do not tell the user a page is new when it already had a URL.',
|
|
133
136
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
134
137
|
inputSchema: {
|
|
@@ -4,7 +4,19 @@ import { basename, extname, join, isAbsolute } from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
jsonResult,
|
|
9
|
+
errorResult,
|
|
10
|
+
makeClient,
|
|
11
|
+
requireWorkspace,
|
|
12
|
+
resolveNodeId,
|
|
13
|
+
NODE_TARGET_HINT,
|
|
14
|
+
NODE_URL_HINT,
|
|
15
|
+
resolveOptionalNodeId,
|
|
16
|
+
assertLocalFilesAllowed,
|
|
17
|
+
isHostedMcp,
|
|
18
|
+
localFileArg,
|
|
19
|
+
} from './shared.js';
|
|
8
20
|
import { RootrApiError } from '../client.js';
|
|
9
21
|
|
|
10
22
|
/** 100MB — the backend's hard limit on a single audio upload. */
|
|
@@ -25,8 +37,11 @@ const AUDIO_MIME_BY_EXT = {
|
|
|
25
37
|
|
|
26
38
|
/** Reads file bytes from a local path or a base64 string; exactly one must be given. */
|
|
27
39
|
function readAudioBytes({ filePath, base64Data }) {
|
|
40
|
+
// SEC-MCP-FILES — 원격 커넥터에서는 서버 파일을 읽게 되므로 읽기 전에 거절한다.
|
|
41
|
+
assertLocalFilesAllowed(filePath);
|
|
28
42
|
if (filePath) return readFileSync(filePath);
|
|
29
43
|
if (base64Data) return Buffer.from(base64Data, 'base64');
|
|
44
|
+
if (isHostedMcp()) throw new RootrApiError('base64Data 가 필요합니다 (원격 커넥터에서는 filePath 를 쓸 수 없습니다).');
|
|
30
45
|
throw new RootrApiError('filePath 또는 base64Data 중 하나가 필요합니다.');
|
|
31
46
|
}
|
|
32
47
|
|
|
@@ -133,10 +148,12 @@ export function registerRecordingTools(server) {
|
|
|
133
148
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
134
149
|
inputSchema: {
|
|
135
150
|
recordingId: z.string().describe(`RECORDING node id${NODE_TARGET_HINT} (create one with rootr_create_recording)`),
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
151
|
+
...localFileArg(
|
|
152
|
+
z
|
|
153
|
+
.string()
|
|
154
|
+
.optional()
|
|
155
|
+
.describe('Local filesystem path to the audio file (the MCP server reads this itself, e.g. "/tmp/meeting.m4a")'),
|
|
156
|
+
),
|
|
140
157
|
base64Data: z
|
|
141
158
|
.string()
|
|
142
159
|
.optional()
|
|
@@ -380,13 +397,15 @@ export function registerRecordingTools(server) {
|
|
|
380
397
|
annotations: { readOnlyHint: true },
|
|
381
398
|
inputSchema: {
|
|
382
399
|
recordingId: z.string().describe(`RECORDING node id${NODE_TARGET_HINT}`),
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
'
|
|
389
|
-
|
|
400
|
+
...localFileArg(
|
|
401
|
+
z
|
|
402
|
+
.string()
|
|
403
|
+
.optional()
|
|
404
|
+
.describe(
|
|
405
|
+
'Where to save the audio ON THE MACHINE RUNNING THIS MCP SERVER. A directory saves under the ' +
|
|
406
|
+
'original filename. Omit to use a temp file. Ignored when urlOnly is true.',
|
|
407
|
+
),
|
|
408
|
+
),
|
|
390
409
|
urlOnly: z
|
|
391
410
|
.boolean()
|
|
392
411
|
.optional()
|
|
@@ -403,18 +422,23 @@ export function registerRecordingTools(server) {
|
|
|
403
422
|
}
|
|
404
423
|
const { attachmentId, filename, mimeType, size } = rec.audio;
|
|
405
424
|
const url = client.attachmentRawUrl(attachmentId);
|
|
406
|
-
|
|
425
|
+
// SEC-MCP-FILES — 원격 커넥터에서는 서버에 파일을 쓰지 않는다. filePath 를 주면 거절하고,
|
|
426
|
+
// 안 줘도(예전엔 서버 임시 폴더에 썼다) 주소만 돌려준다.
|
|
427
|
+
assertLocalFilesAllowed(filePath);
|
|
428
|
+
if (urlOnly || isHostedMcp()) {
|
|
407
429
|
return jsonResult({ audioUrl: url, filename, mimeType, size, durationSec: rec.durationSec ?? null });
|
|
408
430
|
}
|
|
409
431
|
const data = await client.downloadAttachmentBytes(attachmentId);
|
|
410
432
|
// A directory (existing path ending in a separator, or no extension) →
|
|
411
433
|
// keep the original filename; otherwise treat it as the target file.
|
|
434
|
+
// 저장 이름은 오디오를 올린 쪽이 정한 값이다 — 경로 조각(../)이 섞여 있어도 파일 이름만 쓴다.
|
|
435
|
+
const safeName = basename(String(filename || 'audio')) || 'audio';
|
|
412
436
|
let target = filePath?.trim();
|
|
413
437
|
if (!target) {
|
|
414
|
-
target = join(tmpdir(), `rootr-${recordingId}-${
|
|
438
|
+
target = join(tmpdir(), `rootr-${recordingId}-${safeName}`);
|
|
415
439
|
} else {
|
|
416
440
|
if (!isAbsolute(target)) target = join(process.cwd(), target);
|
|
417
|
-
if (target.endsWith('/') || !extname(target)) target = join(target,
|
|
441
|
+
if (target.endsWith('/') || !extname(target)) target = join(target, safeName);
|
|
418
442
|
}
|
|
419
443
|
writeFileSync(target, data);
|
|
420
444
|
return jsonResult({
|
package/lib/mcp-tools/shared.js
CHANGED
|
@@ -53,6 +53,34 @@ export function jsonResult(value) {
|
|
|
53
53
|
return textResult(JSON.stringify(value, null, 2));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* SEC-MCP-FILES — 원격 커넥터(호스티드 `/mcp`)에서는 "MCP 서버가 도는 기계"가 곧 API 서버다.
|
|
58
|
+
* `filePath` 처럼 그 기계의 파일을 읽고 쓰는 인자를 받으면, 호출자가 서버 파일(.env 등)을 첨부로
|
|
59
|
+
* 읽어 가거나 서버 아무 경로에 파일을 쓸 수 있다. 원격이면 무조건 거절한다(stdio 는 사용자
|
|
60
|
+
* 자기 기계라 그대로). 스키마에서 빼는 것(`localFileArg`)과 여기 거절, 두 겹으로 막는다.
|
|
61
|
+
*/
|
|
62
|
+
export function isHostedMcp() {
|
|
63
|
+
return !!currentClientOverride();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function assertLocalFilesAllowed(filePath) {
|
|
67
|
+
if (!currentClientOverride()) return;
|
|
68
|
+
if (filePath === undefined || filePath === null || filePath === '') return;
|
|
69
|
+
throw new RootrApiError('원격 커넥터에서는 filePath 를 쓸 수 없습니다.', {
|
|
70
|
+
hint:
|
|
71
|
+
'filePath 는 MCP 서버가 도는 기계의 파일을 가리킵니다. 원격 커넥터에서는 그 기계가 Rootr 서버라 ' +
|
|
72
|
+
'쓸 수 없습니다 — 파일 내용을 base64Data 로 보내거나, 로컬 rootr CLI(npx rootr-cli mcp)를 쓰세요.',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 도구 입력 스키마에 넣을 `filePath` 칸. 원격 커넥터에서는 빈 객체를 돌려줘 칸 자체가 스키마에서
|
|
78
|
+
* 빠진다(모델이 시도하지 않게) — `...localFileArg(z.string()…)` 로 펼쳐 쓴다.
|
|
79
|
+
*/
|
|
80
|
+
export function localFileArg(schema) {
|
|
81
|
+
return currentClientOverride() ? {} : { filePath: schema };
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/**
|
|
57
85
|
* Build a RootrClient. If `workspaceOverride` is given, it wins over the
|
|
58
86
|
* configured/env workspace — this backs the optional `workspace` argument
|