rootr-cli 0.18.0 → 0.20.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 +56 -2
- package/README.md +52 -5
- package/bin/rootr.js +483 -13
- package/lib/client.js +220 -24
- package/lib/credentials.js +64 -7
- package/lib/mcp-tools/ask.js +6 -1
- package/lib/mcp-tools/attachments.js +153 -15
- 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 +197 -79
- package/lib/mcp.js +3 -0
- package/lib/resolve.js +43 -1
- package/package.json +1 -1
|
@@ -3,8 +3,27 @@ import { z } from 'zod';
|
|
|
3
3
|
import { textResult, jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
|
|
4
4
|
import { loadConfig } from '../config.js';
|
|
5
5
|
import { currentClientOverride } from '../context.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
candidateClients,
|
|
8
|
+
rememberSessionKey,
|
|
9
|
+
rememberWorkspace,
|
|
10
|
+
resolveWorkspaceSwitch,
|
|
11
|
+
surveyWorkspaces,
|
|
12
|
+
} from '../credentials.js';
|
|
7
13
|
import { parseRootrUrl } from '../resolve.js';
|
|
14
|
+
import { t as tr } from '../i18n.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* ISSUE-221 — the workspace this server process was pinned to at launch via the
|
|
18
|
+
* ROOTR_WORKSPACE env var ('' when it was not). Read once, before any switch
|
|
19
|
+
* rewrites process.env: a switch on an env-pinned server only lasts as long as
|
|
20
|
+
* the process does (the MCP client passes the same env again on every restart
|
|
21
|
+
* or reconnect, and env deliberately wins over the config file), so the switch
|
|
22
|
+
* reply has to say where the server will be after a restart — otherwise the
|
|
23
|
+
* next write after a reconnect lands in the pinned workspace while the agent
|
|
24
|
+
* still believes it is in the one it switched to.
|
|
25
|
+
*/
|
|
26
|
+
const LAUNCH_WORKSPACE = process.env.ROOTR_WORKSPACE || '';
|
|
8
27
|
|
|
9
28
|
/**
|
|
10
29
|
* Scaffold `tree` argument schema. Declared as an ARRAY of node objects so MCP
|
|
@@ -19,7 +38,12 @@ const ACCESS_HINT =
|
|
|
19
38
|
'`access` can see it and everything under it) plus `access: [{ subjectId: "<userId>", level: "READ"|"WRITE"|' +
|
|
20
39
|
'"MANAGE" }]` — that pair is how "make a folder these three people can see" happens in one call instead of ' +
|
|
21
40
|
'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).'
|
|
41
|
+
'lock) and `database.notifications` (who to notify on row changes). A database property may carry ' +
|
|
42
|
+
'`searchExcluded: true` (values stay out of the search index behind ask / AI explore / similar rows — not a read permission) and a `type: "vector"` ' +
|
|
43
|
+
'property needs `dimensions` (an embedding column agents fill with rootr_set_row_vectors). ' +
|
|
44
|
+
'A DOCUMENT\'s `content` may embed a live ' +
|
|
45
|
+
'table/sheet/deck with a line `[[embed:<name of a node in this same tree>]]` or `[[embed:/absolute/path]]` — it is ' +
|
|
46
|
+
'linked to the real node after the whole tree is built (an unknown name rolls the whole tree back).';
|
|
23
47
|
|
|
24
48
|
const treeArg = (desc) => z.array(z.record(z.any())).or(z.string()).describe(desc + ACCESS_HINT);
|
|
25
49
|
|
|
@@ -44,6 +68,8 @@ export function registerWorkspaceTools(server) {
|
|
|
44
68
|
'reconfiguration, no restart. With an account key (PAT) you can switch to any workspace you belong ' +
|
|
45
69
|
'to; if separate workspace keys are configured, the one that covers the target is selected and ' +
|
|
46
70
|
'remembered automatically. Call rootr_workspaces first if you need to see what is available. ' +
|
|
71
|
+
'If the server was started with the ROOTR_WORKSPACE env var, the switch lasts until the server ' +
|
|
72
|
+
'restarts (the reply says so) — after a restart/reconnect, check rootr_workspaces again. ' +
|
|
47
73
|
'(A hosted/remote connector is bound to a single workspace at consent time and cannot switch.)',
|
|
48
74
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
49
75
|
inputSchema: {
|
|
@@ -73,8 +99,55 @@ export function registerWorkspaceTools(server) {
|
|
|
73
99
|
const match = await resolveWorkspaceSwitch(workspace, cfg);
|
|
74
100
|
if (match.error) return errorResult(new Error(match.error));
|
|
75
101
|
rememberWorkspace(match, cfg);
|
|
102
|
+
// ISSUE-172 — rememberWorkspace() only writes ~/.rootr/config.json.
|
|
103
|
+
// loadConfig()/keyForWorkspace() both give ROOTR_WORKSPACE/ROOTR_API_KEY
|
|
104
|
+
// env vars priority over that file (deliberately — see config.js
|
|
105
|
+
// ENV-WINS comment), so on a server launched with those env vars set
|
|
106
|
+
// (the common case for MCP client configs that pass env: {...}) every
|
|
107
|
+
// later tool call kept reading the OLD workspace/key from the
|
|
108
|
+
// process's environment no matter what got written to disk. The tool
|
|
109
|
+
// still replied with a success message, so the switch looked like it
|
|
110
|
+
// worked while nothing after it actually moved. Fix: mutate this
|
|
111
|
+
// process's env too, so the very next loadConfig() call (same
|
|
112
|
+
// process, same request loop) picks up the switch immediately.
|
|
113
|
+
if (process.env.ROOTR_WORKSPACE) process.env.ROOTR_WORKSPACE = match.workspace.id;
|
|
114
|
+
if (process.env.ROOTR_API_KEY && match.candidate && match.candidate.apiKey !== process.env.ROOTR_API_KEY) {
|
|
115
|
+
// ISSUE-221 — the key being replaced may exist nowhere but in this
|
|
116
|
+
// process's environment; keep it so switching back still works.
|
|
117
|
+
rememberSessionKey(process.env.ROOTR_API_KEY);
|
|
118
|
+
process.env.ROOTR_API_KEY = match.candidate.apiKey;
|
|
119
|
+
}
|
|
120
|
+
// Verify the switch actually took before reporting success — never
|
|
121
|
+
// report a switch that didn't actually move the active workspace.
|
|
122
|
+
const after = loadConfig();
|
|
123
|
+
if (after.workspace !== match.workspace.id) {
|
|
124
|
+
return errorResult(
|
|
125
|
+
new Error(
|
|
126
|
+
`워크스페이스를 "${match.workspace.id}" 로 바꾸지 못했습니다 — 이 서버 프로세스가 ROOTR_WORKSPACE 환경변수로 ` +
|
|
127
|
+
'고정되어 있어 전환이 적용되지 않습니다.\n' +
|
|
128
|
+
'조치: MCP 서버를 실행하는 환경설정에서 ROOTR_WORKSPACE 환경변수를 제거하거나 원하는 워크스페이스 id로 바꾼 뒤 ' +
|
|
129
|
+
'서버를 재시작하세요.',
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
76
133
|
const name = match.workspace.name || match.workspace.slug || '';
|
|
77
|
-
|
|
134
|
+
// ISSUE-221 — say how long the switch lasts when the server is env-pinned.
|
|
135
|
+
const pinnedNote =
|
|
136
|
+
LAUNCH_WORKSPACE && LAUNCH_WORKSPACE !== match.workspace.id
|
|
137
|
+
? tr(
|
|
138
|
+
`\n주의: 이 MCP 서버는 ROOTR_WORKSPACE 환경변수(${LAUNCH_WORKSPACE})로 고정되어 시작됐습니다. 이 전환은 서버가 ` +
|
|
139
|
+
`켜져 있는 동안만 유효하고, 서버가 재시작(재연결)되면 ${LAUNCH_WORKSPACE} 로 돌아갑니다 — 재시작 뒤에는 쓰기 전에 ` +
|
|
140
|
+
'rootr_workspaces 로 현재 워크스페이스를 다시 확인하세요. 계속 이 워크스페이스를 쓰려면 MCP 서버 설정의 ' +
|
|
141
|
+
`ROOTR_WORKSPACE 를 ${match.workspace.id} 로 바꾸거나 지우세요 (사용자가 직접).`,
|
|
142
|
+
`\nNote: this MCP server was started pinned to ROOTR_WORKSPACE=${LAUNCH_WORKSPACE}. The switch lasts only while ` +
|
|
143
|
+
`the server keeps running; after a restart/reconnect it is back on ${LAUNCH_WORKSPACE} — check rootr_workspaces ` +
|
|
144
|
+
'again before writing. To stay on this workspace, change (or remove) ROOTR_WORKSPACE in the MCP server ' +
|
|
145
|
+
`config to ${match.workspace.id} (a step for the user).`,
|
|
146
|
+
)
|
|
147
|
+
: '';
|
|
148
|
+
return textResult(
|
|
149
|
+
`활성 워크스페이스를 "${name}" (${match.workspace.id}) 로 바꿨습니다. 이후 도구 호출은 여기에 적용됩니다.${pinnedNote}`,
|
|
150
|
+
);
|
|
78
151
|
} catch (err) {
|
|
79
152
|
return errorResult(err);
|
|
80
153
|
}
|
|
@@ -190,79 +263,99 @@ export function registerWorkspaceTools(server) {
|
|
|
190
263
|
},
|
|
191
264
|
);
|
|
192
265
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
'
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
266
|
+
// A hosted/remote connector (ChatGPT, Claude web, etc.) is bound to exactly
|
|
267
|
+
// one workspace chosen at OAuth consent time and its token can never carry
|
|
268
|
+
// `workspaces:create` (see MCP_PERMISSION_SCOPES in mcp-oauth.service.ts —
|
|
269
|
+
// deliberately excluded there). Advertising these two tools anyway meant a
|
|
270
|
+
// hosted session always saw rootr_create_workspace/rootr_delete_workspace in
|
|
271
|
+
// tools/list but every call 403'd with "missing scope: workspaces:create" —
|
|
272
|
+
// the tool looked broken with no way to know why. Only the local stdio CLI
|
|
273
|
+
// (PAT-backed, no client override) can ever satisfy that scope, so skip
|
|
274
|
+
// registering them entirely for hosted sessions.
|
|
275
|
+
if (!currentClientOverride()) {
|
|
276
|
+
server.registerTool(
|
|
277
|
+
'rootr_create_workspace',
|
|
278
|
+
{
|
|
279
|
+
title: 'Create a brand-new Rootr workspace from a scaffold tree',
|
|
280
|
+
description:
|
|
281
|
+
'Create an entirely new Rootr (루터) workspace pre-populated with the given document tree. ' +
|
|
282
|
+
'REQUIRES an account key (PAT) with the workspaces:create scope — a workspace-scoped key cannot do this. ' +
|
|
283
|
+
'PRECONDITION: rootr_scaffold_plan first, ask the user its 3-10 clarifying questions, design the tree from ' +
|
|
284
|
+
'their answers, then run rootr_scaffold_review and fix everything it flags at `error` level. The ' +
|
|
285
|
+
'response carries the same `review` back, so a low score here means you shipped something thin. ' +
|
|
286
|
+
'Build a real tool, not a pile of thin markdown files: mix DATABASE (board/' +
|
|
287
|
+
'timeline views, seed rows), SPREADSHEET (formulas), FORM, ISSUE_TRACKER and rich multi-section DOCUMENTs ' +
|
|
288
|
+
'where they fit, and set an emoji `icon` on folders/databases/key documents. The `tree` shape follows ' +
|
|
289
|
+
'the scaffold schema in llms.txt (max 200 nodes). AFTER creating: if your runtime can receive HTTP ' +
|
|
290
|
+
'callbacks, offer the user to register a webhook on the new tree (rootr_create_webhook with scopeNodeId) ' +
|
|
291
|
+
'so changes flow back to you — skip your own actorId to avoid self-triggering.',
|
|
292
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
293
|
+
inputSchema: {
|
|
294
|
+
name: z.string().describe('Name for the new workspace'),
|
|
295
|
+
intent: z.string().optional().describe('Original natural-language intent, for reference/regeneration'),
|
|
296
|
+
tree: treeArg('Scaffold tree — an ARRAY of node objects to populate the new workspace with (see llms.txt tree schema)'),
|
|
297
|
+
mode: z
|
|
298
|
+
.enum(['docs', 'minimal'])
|
|
299
|
+
.optional()
|
|
300
|
+
.describe(
|
|
301
|
+
'Lower the review bar when the system-building rules do not apply: "docs" for a document-only ' +
|
|
302
|
+
'tree with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s ' +
|
|
303
|
+
'shared ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for ' +
|
|
304
|
+
'normal multi-table workspaces.',
|
|
305
|
+
),
|
|
306
|
+
},
|
|
214
307
|
},
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
);
|
|
308
|
+
async ({ name, intent, tree, mode }) => {
|
|
309
|
+
try {
|
|
310
|
+
const client = makeClient();
|
|
311
|
+
const result = await client.createWorkspaceScaffold({ name, intent, tree: parseTree(tree), mode });
|
|
312
|
+
return jsonResult(result);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return errorResult(err);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
);
|
|
226
318
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
319
|
+
/**
|
|
320
|
+
* WS-CLEANUP — **짓다 실패해서 남은 빈 워크스페이스를 에이전트가 치울 수 있게.**
|
|
321
|
+
*
|
|
322
|
+
* 스캐폴딩이 중간에 실패하면 껍데기만 남는데 지우는 문이 없었다. 무료 한도가
|
|
323
|
+
* 5개라 몇 번 실패하면 사용자가 바로 막히고, 그 잔해를 사람이 화면에서 하나씩
|
|
324
|
+
* 지워야 했다(2026-08-30 실측: 치과 3개·아파트 4개).
|
|
325
|
+
*
|
|
326
|
+
* 이름을 그대로 다시 적게 한다 — 서버가 요구하는 안전장치이자, 모델이 엉뚱한
|
|
327
|
+
* 워크스페이스를 지목했을 때 걸리는 그물이다.
|
|
328
|
+
*/
|
|
329
|
+
server.registerTool(
|
|
330
|
+
'rootr_delete_workspace',
|
|
331
|
+
{
|
|
332
|
+
title: 'Delete a workspace you created (cleanup after a failed scaffold)',
|
|
333
|
+
description:
|
|
334
|
+
'Permanently delete a Rootr (루터) workspace. Intended for cleaning up EMPTY leftovers from a scaffold ' +
|
|
335
|
+
'run that failed halfway — the free plan allows only a handful of workspaces, so abandoned shells block ' +
|
|
336
|
+
'the user from trying again. Requires OWNER on that workspace, and you must pass `confirmName` exactly ' +
|
|
337
|
+
'matching the workspace name (call rootr_workspaces first to read it). THIS CANNOT BE UNDONE: everything ' +
|
|
338
|
+
'inside is removed. Never call this on a workspace that holds the user\'s real work — if you did not ' +
|
|
339
|
+
'create it in this session, ask the user first.',
|
|
340
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
341
|
+
inputSchema: {
|
|
342
|
+
workspaceId: z.string().describe('Id of the workspace to delete'),
|
|
343
|
+
confirmName: z
|
|
344
|
+
.string()
|
|
345
|
+
.describe('The workspace name, typed again exactly — the server rejects the call if it does not match'),
|
|
346
|
+
},
|
|
254
347
|
},
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
348
|
+
async ({ workspaceId, confirmName }) => {
|
|
349
|
+
try {
|
|
350
|
+
const client = makeClient();
|
|
351
|
+
await client.deleteWorkspace({ workspaceId, confirmName });
|
|
352
|
+
return jsonResult({ deleted: true, workspaceId });
|
|
353
|
+
} catch (err) {
|
|
354
|
+
return errorResult(err);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
}
|
|
266
359
|
|
|
267
360
|
server.registerTool(
|
|
268
361
|
'rootr_scaffold_review',
|
|
@@ -280,12 +373,21 @@ export function registerWorkspaceTools(server) {
|
|
|
280
373
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
281
374
|
inputSchema: {
|
|
282
375
|
tree: treeArg('The scaffold tree you designed — an ARRAY of node objects, same shape you would pass to rootr_scaffold_apply'),
|
|
376
|
+
mode: z
|
|
377
|
+
.enum(['docs', 'minimal'])
|
|
378
|
+
.optional()
|
|
379
|
+
.describe(
|
|
380
|
+
'Lower the review bar when the system-building rules do not apply: "docs" for a document-only tree ' +
|
|
381
|
+
'with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s shared ' +
|
|
382
|
+
'ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for normal ' +
|
|
383
|
+
'multi-table workspaces.',
|
|
384
|
+
),
|
|
283
385
|
},
|
|
284
386
|
},
|
|
285
|
-
async ({ tree }) => {
|
|
387
|
+
async ({ tree, mode }) => {
|
|
286
388
|
try {
|
|
287
389
|
const client = makeClient();
|
|
288
|
-
const result = await client.scaffoldReview(parseTree(tree));
|
|
390
|
+
const result = await client.scaffoldReview(parseTree(tree), mode);
|
|
289
391
|
return jsonResult(result);
|
|
290
392
|
} catch (err) {
|
|
291
393
|
return errorResult(err);
|
|
@@ -309,13 +411,20 @@ export function registerWorkspaceTools(server) {
|
|
|
309
411
|
inputSchema: {
|
|
310
412
|
rootPath: z.string().optional().describe('Grade only this subtree, e.g. "/동네 빵집 운영"'),
|
|
311
413
|
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
|
|
414
|
+
mode: z
|
|
415
|
+
.enum(['docs', 'minimal'])
|
|
416
|
+
.optional()
|
|
417
|
+
.describe(
|
|
418
|
+
'Same meaning as on rootr_scaffold_review: "docs" for a document-only subtree, "minimal" for a ' +
|
|
419
|
+
'small personal/few-person subtree where relations/guide/forms/rollups are overkill.',
|
|
420
|
+
),
|
|
312
421
|
},
|
|
313
422
|
},
|
|
314
|
-
async ({ rootPath, workspace }) => {
|
|
423
|
+
async ({ rootPath, workspace, mode }) => {
|
|
315
424
|
try {
|
|
316
425
|
const ws = requireWorkspace(workspace);
|
|
317
426
|
const client = makeClient(ws);
|
|
318
|
-
return jsonResult(await client.scaffoldReviewBuilt(ws, rootPath));
|
|
427
|
+
return jsonResult(await client.scaffoldReviewBuilt(ws, rootPath, mode));
|
|
319
428
|
} catch (err) {
|
|
320
429
|
return errorResult(err);
|
|
321
430
|
}
|
|
@@ -373,13 +482,22 @@ export function registerWorkspaceTools(server) {
|
|
|
373
482
|
.string()
|
|
374
483
|
.optional()
|
|
375
484
|
.describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
485
|
+
mode: z
|
|
486
|
+
.enum(['docs', 'minimal'])
|
|
487
|
+
.optional()
|
|
488
|
+
.describe(
|
|
489
|
+
'Lower the review bar when the system-building rules do not apply: "docs" for a document-only tree ' +
|
|
490
|
+
'with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s shared ' +
|
|
491
|
+
'ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for normal ' +
|
|
492
|
+
'multi-table workspaces.',
|
|
493
|
+
),
|
|
376
494
|
},
|
|
377
495
|
},
|
|
378
|
-
async ({ tree, rootPath, workspace }) => {
|
|
496
|
+
async ({ tree, rootPath, workspace, mode }) => {
|
|
379
497
|
try {
|
|
380
498
|
const ws = requireWorkspace(workspace);
|
|
381
499
|
const client = makeClient(ws);
|
|
382
|
-
const result = await client.scaffoldApply(ws, { rootPath, tree: parseTree(tree) });
|
|
500
|
+
const result = await client.scaffoldApply(ws, { rootPath, tree: parseTree(tree), mode });
|
|
383
501
|
return jsonResult(result);
|
|
384
502
|
} catch (err) {
|
|
385
503
|
return errorResult(err);
|
package/lib/mcp.js
CHANGED
|
@@ -11,6 +11,8 @@ import { registerTemplateTools } from './mcp-tools/templates.js';
|
|
|
11
11
|
import { registerWebhookTools } from './mcp-tools/webhooks.js';
|
|
12
12
|
import { registerMiscTools } from './mcp-tools/misc.js';
|
|
13
13
|
import { registerPublicTools } from './mcp-tools/public.js';
|
|
14
|
+
// BOOKING — 공개한 표를 예약 페이지로 쓰는 도구 + 구글 캘린더 연결.
|
|
15
|
+
import { registerBookingTools } from './mcp-tools/booking.js';
|
|
14
16
|
import { registerCrmTools } from './mcp-tools/crm.js';
|
|
15
17
|
import { registerPresentationTools } from './mcp-tools/presentations.js';
|
|
16
18
|
import { registerPageTools } from './mcp-tools/pages.js';
|
|
@@ -272,6 +274,7 @@ export function buildMcpServer({ name, version }) {
|
|
|
272
274
|
registerWebhookTools(server);
|
|
273
275
|
registerMiscTools(server);
|
|
274
276
|
registerPublicTools(server);
|
|
277
|
+
registerBookingTools(server);
|
|
275
278
|
registerCrmTools(server);
|
|
276
279
|
registerPresentationTools(server);
|
|
277
280
|
registerPageTools(server);
|
package/lib/resolve.js
CHANGED
|
@@ -30,6 +30,26 @@ export function parseRootrUrl(target) {
|
|
|
30
30
|
return { workspaceId: segments[at + 1], nodeId: id, kind: id ? kind : undefined };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Parse a published public document URL: `rootr.io/p/{slug}` (optionally
|
|
35
|
+
* locale-prefixed, e.g. `/ko/p/{slug}`). This is the "share this doc"
|
|
36
|
+
* address — it carries no workspace id, unlike `/w/{ws}/...` URLs, so it
|
|
37
|
+
* needs its own recognizer. Returns null for anything else.
|
|
38
|
+
*/
|
|
39
|
+
export function parsePublicSlugUrl(target) {
|
|
40
|
+
if (typeof target !== 'string' || !/^https?:\/\//i.test(target)) return null;
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(target);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
48
|
+
const at = segments.indexOf('p');
|
|
49
|
+
if (at === -1 || !segments[at + 1]) return null;
|
|
50
|
+
return { slug: segments[at + 1] };
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
/**
|
|
34
54
|
* Resolve a CLI/MCP "target" argument to a node id.
|
|
35
55
|
* A full rootr.io URL is unpacked to the node id it points at; a value
|
|
@@ -78,6 +98,27 @@ export async function resolveTarget(client, target) {
|
|
|
78
98
|
return { id: url.nodeId, existed: true, path: undefined, url };
|
|
79
99
|
}
|
|
80
100
|
|
|
101
|
+
const publicUrl = parsePublicSlugUrl(target);
|
|
102
|
+
if (publicUrl) {
|
|
103
|
+
if (!client.workspace) {
|
|
104
|
+
throw new RootrApiError(
|
|
105
|
+
tr(
|
|
106
|
+
`이 주소는 공개 문서 주소(/p/${publicUrl.slug})입니다. 먼저 rootr_use_workspace 로 워크스페이스를 선택하세요.`,
|
|
107
|
+
`This is a public document URL (/p/${publicUrl.slug}). Select a workspace with rootr_use_workspace first.`,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const node = await client.getNodeBySlug(publicUrl.slug);
|
|
112
|
+
if (!node) {
|
|
113
|
+
// ISSUE-127 — 못 찾은 공개 주소는 "새로 만들 경로"가 아니다. 예전에는 path 에 주소를 그대로 실어, rootr_write 가
|
|
114
|
+
// `/https:/rootr.io/p/<slug>` 라는 폴더 줄기와 헛문서를 만들었다(격리 dev 실측). path 는 비우고 주소는 target 으로만 남긴다.
|
|
115
|
+
// publicSlug 는 rootr_read 가 공개 발행본으로 되받을 때만 쓴다(쓰기 도구는 안 본다).
|
|
116
|
+
return { id: undefined, existed: false, path: undefined, target, publicSlug: publicUrl.slug };
|
|
117
|
+
}
|
|
118
|
+
const id = node.id || (node.node && node.node.id);
|
|
119
|
+
return { id, existed: true, path: undefined, node: node.node || node };
|
|
120
|
+
}
|
|
121
|
+
|
|
81
122
|
if (!target.startsWith('/')) {
|
|
82
123
|
return { id: target, existed: true, path: undefined };
|
|
83
124
|
}
|
|
@@ -92,7 +133,8 @@ export async function resolveTarget(client, target) {
|
|
|
92
133
|
|
|
93
134
|
export function requireResolved(resolved) {
|
|
94
135
|
if (!resolved.existed || !resolved.id) {
|
|
95
|
-
|
|
136
|
+
const what = resolved.path ?? resolved.target;
|
|
137
|
+
throw new RootrApiError(tr(`경로를 찾을 수 없습니다: ${what}`, `Path not found: ${what}`));
|
|
96
138
|
}
|
|
97
139
|
return resolved.id;
|
|
98
140
|
}
|