rootr-cli 0.2.0 → 0.4.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/LICENSE +21 -0
- package/README.md +94 -4
- package/bin/rootr.js +109 -3
- package/lib/client.js +531 -1
- package/lib/config.js +30 -1
- package/lib/context.js +38 -0
- package/lib/mcp-tools/ask.js +1 -0
- package/lib/mcp-tools/attachments.js +110 -0
- package/lib/mcp-tools/crm.js +711 -0
- package/lib/mcp-tools/databases.js +5 -0
- package/lib/mcp-tools/documents.js +153 -1
- package/lib/mcp-tools/forms.js +206 -0
- package/lib/mcp-tools/issues.js +6 -0
- package/lib/mcp-tools/logs.js +5 -0
- package/lib/mcp-tools/misc.js +2 -0
- package/lib/mcp-tools/presentations.js +356 -0
- package/lib/mcp-tools/recordings.js +426 -0
- package/lib/mcp-tools/shared.js +18 -3
- package/lib/mcp-tools/spreadsheets.js +224 -0
- package/lib/mcp-tools/webhooks.js +3 -0
- package/lib/mcp-tools/whiteboards.js +166 -0
- package/lib/mcp-tools/workspaces.js +172 -9
- package/lib/mcp.js +98 -5
- package/package.json +22 -3
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, extname, join, isAbsolute } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
import { jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
|
|
8
|
+
import { RootrApiError } from '../client.js';
|
|
9
|
+
|
|
10
|
+
/** 100MB — the backend's hard limit on a single audio upload. */
|
|
11
|
+
const MAX_AUDIO_BYTES = 100 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/** 확장자 → 오디오 MIME 추론 (filePath만 주면 filename/mimeType을 알아서 채운다). */
|
|
14
|
+
const AUDIO_MIME_BY_EXT = {
|
|
15
|
+
'.webm': 'audio/webm',
|
|
16
|
+
'.m4a': 'audio/mp4',
|
|
17
|
+
'.mp4': 'audio/mp4',
|
|
18
|
+
'.mp3': 'audio/mpeg',
|
|
19
|
+
'.wav': 'audio/wav',
|
|
20
|
+
'.ogg': 'audio/ogg',
|
|
21
|
+
'.oga': 'audio/ogg',
|
|
22
|
+
'.flac': 'audio/flac',
|
|
23
|
+
'.aac': 'audio/aac',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Reads file bytes from a local path or a base64 string; exactly one must be given. */
|
|
27
|
+
function readAudioBytes({ filePath, base64Data }) {
|
|
28
|
+
if (filePath) return readFileSync(filePath);
|
|
29
|
+
if (base64Data) return Buffer.from(base64Data, 'base64');
|
|
30
|
+
throw new RootrApiError('filePath 또는 base64Data 중 하나가 필요합니다.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pad2(n) {
|
|
34
|
+
return String(n).padStart(2, '0');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clock(totalSec) {
|
|
38
|
+
const sec = Math.max(0, Math.floor(totalSec || 0));
|
|
39
|
+
const h = Math.floor(sec / 3600);
|
|
40
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
41
|
+
const s = sec % 60;
|
|
42
|
+
return h > 0 ? `${h}:${pad2(m)}:${pad2(s)}` : `${pad2(m)}:${pad2(s)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function speakerLabel(key, names) {
|
|
46
|
+
const custom = (names?.[key] || '').trim();
|
|
47
|
+
if (custom) return custom;
|
|
48
|
+
const m = /^S(\d+)$/i.exec(String(key).trim());
|
|
49
|
+
return m ? `Speaker ${m[1]}` : String(key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* REC-001 — RECORDING node (audio recording → diarized transcript → minutes).
|
|
54
|
+
* The read tool is the seam that lets a user's OWN agent do the summarization
|
|
55
|
+
* (no Rootr AI credits): read the transcript here, write the minutes with
|
|
56
|
+
* rootr_write. Audio bytes cannot be uploaded through MCP — recording happens
|
|
57
|
+
* in the Rootr web app.
|
|
58
|
+
*/
|
|
59
|
+
export function registerRecordingTools(server) {
|
|
60
|
+
server.registerTool(
|
|
61
|
+
'rootr_create_recording',
|
|
62
|
+
{
|
|
63
|
+
title: 'Create a Rootr recording node',
|
|
64
|
+
description:
|
|
65
|
+
'Create a new RECORDING node (audio + diarized transcript + minutes) in a Rootr (루터) workspace. ' +
|
|
66
|
+
'The node starts EMPTY. Typical flow: rootr_create_recording → rootr_upload_recording_audio (stores the ' +
|
|
67
|
+
'audio, status becomes UPLOADED) → rootr_transcribe_recording (explicitly starts speaker-labeled ' +
|
|
68
|
+
'speech-to-text; nothing is transcribed until you call it) → poll rootr_read_recording until status is ' +
|
|
69
|
+
'READY → read the transcript (and optionally name the speakers with rootr_rename_recording_speakers) → ' +
|
|
70
|
+
'write the minutes yourself with rootr_write, or call rootr_summarize_recording to use Rootr\'s built-in ' +
|
|
71
|
+
'summarizer.',
|
|
72
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
73
|
+
inputSchema: {
|
|
74
|
+
name: z.string().optional().describe('Recording name/title, e.g. "2026-07-21 주간회의"'),
|
|
75
|
+
parentId: z.string().optional().describe('Parent folder/node id to create the recording under'),
|
|
76
|
+
path: z
|
|
77
|
+
.string()
|
|
78
|
+
.optional()
|
|
79
|
+
.describe('Tree path to create at, e.g. "/회의록/2026-07-21 주간회의" (alternative to parentId)'),
|
|
80
|
+
createParents: z
|
|
81
|
+
.boolean()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe('When using `path`, create any missing parent folders (default false)'),
|
|
84
|
+
icon: z.string().optional().describe('Emoji icon for the recording node'),
|
|
85
|
+
expectedSpeakers: z
|
|
86
|
+
.array(z.string())
|
|
87
|
+
.optional()
|
|
88
|
+
.describe(
|
|
89
|
+
'Known attendees, e.g. ["김대표","이팀장"]. A naming HINT for transcription: a voice that ' +
|
|
90
|
+
'introduces itself or is addressed by name gets that name as its speaker label instead of S1. ' +
|
|
91
|
+
'Voices the model cannot identify stay anonymous — set the mapping afterwards with ' +
|
|
92
|
+
'rootr_rename_recording_speakers.',
|
|
93
|
+
),
|
|
94
|
+
workspace: z
|
|
95
|
+
.string()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
async ({ name, parentId, path, createParents, icon, expectedSpeakers, workspace }) => {
|
|
101
|
+
try {
|
|
102
|
+
const ws = requireWorkspace(workspace);
|
|
103
|
+
const client = makeClient(ws);
|
|
104
|
+
const result = await client.createRecording(ws, {
|
|
105
|
+
name,
|
|
106
|
+
parentId,
|
|
107
|
+
path,
|
|
108
|
+
createParents,
|
|
109
|
+
icon,
|
|
110
|
+
expectedSpeakers,
|
|
111
|
+
});
|
|
112
|
+
return jsonResult(result);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return errorResult(err);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
server.registerTool(
|
|
120
|
+
'rootr_upload_recording_audio',
|
|
121
|
+
{
|
|
122
|
+
title: 'Upload audio to a Rootr recording (starts transcription)',
|
|
123
|
+
description:
|
|
124
|
+
'Upload an audio file (meeting/interview/call recording — .webm .m4a .mp4 .mp3 .wav .ogg .flac .aac, ' +
|
|
125
|
+
'max 100MB) into an existing Rootr (루터) RECORDING node. This is how audio gets into Rootr from an agent: ' +
|
|
126
|
+
'give exactly one of filePath (the MCP server reads the file from the local filesystem itself) or ' +
|
|
127
|
+
'base64Data (bytes already in memory). filename and mimeType are inferred from filePath when omitted. ' +
|
|
128
|
+
'This ONLY stores the audio (status becomes UPLOADED) and REPLACES any previous audio — it does NOT ' +
|
|
129
|
+
'transcribe anything and spends no AI credits. To get a transcript you must then explicitly call ' +
|
|
130
|
+
'rootr_transcribe_recording.',
|
|
131
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
132
|
+
inputSchema: {
|
|
133
|
+
recordingId: z.string().describe('RECORDING node id (create one with rootr_create_recording)'),
|
|
134
|
+
filePath: z
|
|
135
|
+
.string()
|
|
136
|
+
.optional()
|
|
137
|
+
.describe('Local filesystem path to the audio file (the MCP server reads this itself, e.g. "/tmp/meeting.m4a")'),
|
|
138
|
+
base64Data: z
|
|
139
|
+
.string()
|
|
140
|
+
.optional()
|
|
141
|
+
.describe('Base64-encoded audio bytes; use this when the caller has the audio in memory instead of on disk'),
|
|
142
|
+
filename: z
|
|
143
|
+
.string()
|
|
144
|
+
.optional()
|
|
145
|
+
.describe('File name to store, e.g. "meeting.m4a". Inferred from filePath when omitted.'),
|
|
146
|
+
mimeType: z
|
|
147
|
+
.string()
|
|
148
|
+
.optional()
|
|
149
|
+
.describe('Audio MIME type, e.g. "audio/mp4". Inferred from the file extension when omitted.'),
|
|
150
|
+
durationSec: z
|
|
151
|
+
.number()
|
|
152
|
+
.optional()
|
|
153
|
+
.describe('Audio length in seconds, if known (shown in the UI; the server may also detect it)'),
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
async ({ recordingId, filePath, base64Data, filename, mimeType, durationSec }) => {
|
|
157
|
+
try {
|
|
158
|
+
const client = makeClient();
|
|
159
|
+
const data = readAudioBytes({ filePath, base64Data });
|
|
160
|
+
if (data.length > MAX_AUDIO_BYTES) {
|
|
161
|
+
throw new RootrApiError(
|
|
162
|
+
`오디오가 너무 큽니다: ${(data.length / 1024 / 1024).toFixed(1)}MB (최대 100MB).`,
|
|
163
|
+
{ hint: '더 낮은 비트레이트로 다시 인코딩하거나(예: 64kbps mono m4a) 파일을 나눠서 올리세요.' },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const name = filename || (filePath ? basename(filePath) : null);
|
|
167
|
+
if (!name) {
|
|
168
|
+
throw new RootrApiError('base64Data를 쓸 때는 filename이 필요합니다.');
|
|
169
|
+
}
|
|
170
|
+
const ext = extname(name).toLowerCase();
|
|
171
|
+
const type = mimeType || AUDIO_MIME_BY_EXT[ext];
|
|
172
|
+
if (!type) {
|
|
173
|
+
throw new RootrApiError(
|
|
174
|
+
`오디오 MIME 타입을 알 수 없습니다 (확장자: ${ext || '없음'}). mimeType을 직접 지정하세요.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const result = await client.uploadRecordingAudio(recordingId, {
|
|
178
|
+
filename: name,
|
|
179
|
+
mimeType: type,
|
|
180
|
+
durationSec,
|
|
181
|
+
data,
|
|
182
|
+
});
|
|
183
|
+
return jsonResult({
|
|
184
|
+
...result,
|
|
185
|
+
uploadedBytes: data.length,
|
|
186
|
+
next:
|
|
187
|
+
'Audio stored (status UPLOADED). No transcript exists yet — call rootr_transcribe_recording to ' +
|
|
188
|
+
'produce the diarized transcript (this is the step that spends AI credits).',
|
|
189
|
+
});
|
|
190
|
+
} catch (err) {
|
|
191
|
+
return errorResult(err);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
server.registerTool(
|
|
197
|
+
'rootr_read_recording',
|
|
198
|
+
{
|
|
199
|
+
title: 'Read a Rootr recording transcript',
|
|
200
|
+
description:
|
|
201
|
+
'Read a Rootr (루터) RECORDING node — status, speaker names and the full diarized transcript ' +
|
|
202
|
+
'(segments + a ready-to-use "[mm:ss] Speaker: text" rendering). Use this to summarize a meeting with ' +
|
|
203
|
+
'YOUR own reasoning instead of Rootr\'s built-in summarize (which spends the workspace\'s AI credits): ' +
|
|
204
|
+
'read the transcript, then create the minutes document with rootr_write. ' +
|
|
205
|
+
'status EMPTY = no audio yet (upload with rootr_upload_recording_audio); ' +
|
|
206
|
+
'UPLOADED = audio stored but no transcript yet — call rootr_transcribe_recording to create one; ' +
|
|
207
|
+
'TRANSCRIBING = speech-to-text still running (poll again later); ' +
|
|
208
|
+
'READY = transcript available; FAILED = last transcription errored (see `error`).',
|
|
209
|
+
inputSchema: {
|
|
210
|
+
recordingId: z.string().describe('RECORDING node id'),
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
async ({ recordingId }) => {
|
|
214
|
+
try {
|
|
215
|
+
const client = makeClient();
|
|
216
|
+
const result = await client.getRecording(recordingId);
|
|
217
|
+
const segments = result?.transcript?.segments ?? [];
|
|
218
|
+
const names = result?.speakerNames ?? {};
|
|
219
|
+
const transcriptText = segments
|
|
220
|
+
.map((s) => `[${clock(s.start)}] ${speakerLabel(s.speaker, names)}: ${s.text}`)
|
|
221
|
+
.join('\n');
|
|
222
|
+
// audioUrl lets an agent fetch the audio itself (e.g. to run its own
|
|
223
|
+
// speech-to-text) instead of spending Rootr credits on transcription.
|
|
224
|
+
const audioUrl = result?.audio?.attachmentId
|
|
225
|
+
? client.attachmentRawUrl(result.audio.attachmentId)
|
|
226
|
+
: null;
|
|
227
|
+
return jsonResult({ ...result, audioUrl, transcriptText });
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return errorResult(err);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
server.registerTool(
|
|
235
|
+
'rootr_rename_recording_speakers',
|
|
236
|
+
{
|
|
237
|
+
title: 'Rename diarized speakers in a Rootr recording',
|
|
238
|
+
description:
|
|
239
|
+
'Assign display names to the diarized speaker labels of a Rootr (루터) RECORDING node, e.g. ' +
|
|
240
|
+
'{"S1":"김대표","S2":"이팀장"}. Merge-patch: only the keys you send change. The names apply to the ' +
|
|
241
|
+
'transcript everywhere (web UI, rootr_read_recording, knowledge graph).',
|
|
242
|
+
inputSchema: {
|
|
243
|
+
recordingId: z.string().describe('RECORDING node id'),
|
|
244
|
+
speakerNames: z
|
|
245
|
+
.record(z.string())
|
|
246
|
+
.optional()
|
|
247
|
+
.describe('Map of speaker key ("S1", "S2", …) to display name, e.g. {"S1":"김개똥","S2":"홍길동"}'),
|
|
248
|
+
expectedSpeakers: z
|
|
249
|
+
.array(z.string())
|
|
250
|
+
.optional()
|
|
251
|
+
.describe(
|
|
252
|
+
'Replace the known-attendee list used as a naming hint on the NEXT transcription run. ' +
|
|
253
|
+
'Does not relabel the current transcript — use speakerNames for that.',
|
|
254
|
+
),
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
async ({ recordingId, speakerNames, expectedSpeakers }) => {
|
|
258
|
+
try {
|
|
259
|
+
if (!speakerNames && !expectedSpeakers) {
|
|
260
|
+
throw new RootrApiError('speakerNames 또는 expectedSpeakers 중 하나는 필요합니다.');
|
|
261
|
+
}
|
|
262
|
+
const client = makeClient();
|
|
263
|
+
const current = await client.getRecording(recordingId);
|
|
264
|
+
const merged = speakerNames
|
|
265
|
+
? { ...(current?.speakerNames ?? {}), ...speakerNames }
|
|
266
|
+
: undefined;
|
|
267
|
+
const result = await client.updateRecording(recordingId, {
|
|
268
|
+
speakerNames: merged,
|
|
269
|
+
expectedSpeakers,
|
|
270
|
+
});
|
|
271
|
+
return jsonResult({
|
|
272
|
+
speakerNames: result.speakerNames,
|
|
273
|
+
expectedSpeakers: result.expectedSpeakers,
|
|
274
|
+
});
|
|
275
|
+
} catch (err) {
|
|
276
|
+
return errorResult(err);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
server.registerTool(
|
|
282
|
+
'rootr_transcribe_recording',
|
|
283
|
+
{
|
|
284
|
+
title: 'Transcribe a Rootr recording (speech-to-text)',
|
|
285
|
+
description:
|
|
286
|
+
'Turn the audio of a Rootr (루터) RECORDING node into a diarized (speaker-labeled) transcript. This is THE ' +
|
|
287
|
+
'tool that creates a transcript — uploading audio does not transcribe by itself, so call this after ' +
|
|
288
|
+
'rootr_upload_recording_audio (status UPLOADED), and call it again to redo a transcript that FAILED or that ' +
|
|
289
|
+
'you want regenerated (it REPLACES the existing transcript; speaker names you set are kept). ' +
|
|
290
|
+
'This spends the workspace\'s Rootr AI credits — only run it when the user actually wants the transcript. ' +
|
|
291
|
+
'Returns the recording with status TRANSCRIBING; poll rootr_read_recording until READY (a long meeting can ' +
|
|
292
|
+
'take a few minutes).',
|
|
293
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
294
|
+
inputSchema: {
|
|
295
|
+
recordingId: z.string().describe('RECORDING node id'),
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
async ({ recordingId }) => {
|
|
299
|
+
try {
|
|
300
|
+
const client = makeClient();
|
|
301
|
+
const result = await client.transcribeRecording(recordingId);
|
|
302
|
+
return jsonResult({
|
|
303
|
+
...result,
|
|
304
|
+
next: 'Poll rootr_read_recording until status is READY (or FAILED — see `error`).',
|
|
305
|
+
});
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return errorResult(err);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
server.registerTool(
|
|
313
|
+
'rootr_summarize_recording',
|
|
314
|
+
{
|
|
315
|
+
title: 'Summarize a Rootr recording with Rootr\'s built-in AI',
|
|
316
|
+
description:
|
|
317
|
+
'Run Rootr\'s BUILT-IN summarizer over a RECORDING node\'s transcript and create a markdown DOCUMENT node ' +
|
|
318
|
+
'from it. Two formats: "summary" (default) = short minutes — one-line outcome, participants, key points, ' +
|
|
319
|
+
'decisions, action items; "onepager" = a detailed hand-off briefing for someone who was NOT in the meeting — ' +
|
|
320
|
+
'background, per-topic prose with quotes, decisions with rationale, an owner/due table, risks and a timeline. ' +
|
|
321
|
+
'Each format has its own document, so creating one does not replace the other. Returns { docId, format } — ' +
|
|
322
|
+
'read or edit it afterwards with rootr_read / rootr_edit. Requires status READY.\n' +
|
|
323
|
+
'IMPORTANT — this spends the workspace\'s Rootr AI credits (a one-pager costs more than a summary). To save ' +
|
|
324
|
+
'credits, do it yourself instead: call rootr_read_recording to get the full transcript, write the document ' +
|
|
325
|
+
'with your own reasoning, and create it with rootr_write. Prefer that path unless the user explicitly asks ' +
|
|
326
|
+
'for Rootr\'s built-in summary.',
|
|
327
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
328
|
+
inputSchema: {
|
|
329
|
+
recordingId: z.string().describe('RECORDING node id (status must be READY)'),
|
|
330
|
+
format: z
|
|
331
|
+
.enum(['summary', 'onepager'])
|
|
332
|
+
.optional()
|
|
333
|
+
.describe(
|
|
334
|
+
'What to write: "summary" (default) = short minutes to skim; "onepager" = the long, detailed briefing ' +
|
|
335
|
+
'for someone who missed the meeting.',
|
|
336
|
+
),
|
|
337
|
+
locale: z
|
|
338
|
+
.enum(['ko', 'en'])
|
|
339
|
+
.optional()
|
|
340
|
+
.describe('Language of the generated document: "ko" (Korean) or "en" (English). Defaults to the workspace locale.'),
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
async ({ recordingId, locale, format }) => {
|
|
344
|
+
try {
|
|
345
|
+
const client = makeClient();
|
|
346
|
+
const result = await client.summarizeRecording(recordingId, { locale, format });
|
|
347
|
+
return jsonResult(result);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
return errorResult(err);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
server.registerTool(
|
|
355
|
+
'rootr_download_recording_audio',
|
|
356
|
+
{
|
|
357
|
+
title: 'Download a Rootr recording\'s audio file',
|
|
358
|
+
description:
|
|
359
|
+
'Fetch the audio of a Rootr (루터) RECORDING node so YOU can process it — most usefully, run your own ' +
|
|
360
|
+
'speech-to-text on it instead of rootr_transcribe_recording (which spends the workspace\'s AI credits). ' +
|
|
361
|
+
'Note that most chat models cannot listen to audio at all (Claude cannot); this is for agents that can ' +
|
|
362
|
+
'reach a speech-to-text model or CLI. Two ways to get the bytes: ' +
|
|
363
|
+
'(a) pass `filePath` (or omit it for a temp file) to save the audio on the machine running this MCP ' +
|
|
364
|
+
'server — right for the local `rootr` CLI, useless over the hosted connector, where the file would land ' +
|
|
365
|
+
'on Rootr\'s server; or (b) pass `urlOnly: true` to get just a direct download URL you can fetch yourself. ' +
|
|
366
|
+
'The URL is also returned by rootr_read_recording as `audioUrl`. ' +
|
|
367
|
+
'Audio is never returned inline as base64 — a meeting recording is tens of megabytes and would blow up ' +
|
|
368
|
+
'the conversation. ' +
|
|
369
|
+
'Rootr has no endpoint for writing an externally-produced transcript back yet, so a transcript you make ' +
|
|
370
|
+
'this way lives wherever you put it (e.g. a document via rootr_write), not in the recording node.',
|
|
371
|
+
annotations: { readOnlyHint: true },
|
|
372
|
+
inputSchema: {
|
|
373
|
+
recordingId: z.string().describe('RECORDING node id'),
|
|
374
|
+
filePath: z
|
|
375
|
+
.string()
|
|
376
|
+
.optional()
|
|
377
|
+
.describe(
|
|
378
|
+
'Where to save the audio ON THE MACHINE RUNNING THIS MCP SERVER. A directory saves under the ' +
|
|
379
|
+
'original filename. Omit to use a temp file. Ignored when urlOnly is true.',
|
|
380
|
+
),
|
|
381
|
+
urlOnly: z
|
|
382
|
+
.boolean()
|
|
383
|
+
.optional()
|
|
384
|
+
.describe('Return only the download URL, without writing any file (default false).'),
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
async ({ recordingId, filePath, urlOnly }) => {
|
|
388
|
+
try {
|
|
389
|
+
const client = makeClient();
|
|
390
|
+
const rec = await client.getRecording(recordingId);
|
|
391
|
+
if (!rec?.audio?.attachmentId) {
|
|
392
|
+
throw new RootrApiError('이 녹음에는 아직 오디오가 없습니다. rootr_upload_recording_audio로 먼저 올리세요.');
|
|
393
|
+
}
|
|
394
|
+
const { attachmentId, filename, mimeType, size } = rec.audio;
|
|
395
|
+
const url = client.attachmentRawUrl(attachmentId);
|
|
396
|
+
if (urlOnly) {
|
|
397
|
+
return jsonResult({ audioUrl: url, filename, mimeType, size, durationSec: rec.durationSec ?? null });
|
|
398
|
+
}
|
|
399
|
+
const data = await client.downloadAttachmentBytes(attachmentId);
|
|
400
|
+
// A directory (existing path ending in a separator, or no extension) →
|
|
401
|
+
// keep the original filename; otherwise treat it as the target file.
|
|
402
|
+
let target = filePath?.trim();
|
|
403
|
+
if (!target) {
|
|
404
|
+
target = join(tmpdir(), `rootr-${recordingId}-${filename}`);
|
|
405
|
+
} else {
|
|
406
|
+
if (!isAbsolute(target)) target = join(process.cwd(), target);
|
|
407
|
+
if (target.endsWith('/') || !extname(target)) target = join(target, filename);
|
|
408
|
+
}
|
|
409
|
+
writeFileSync(target, data);
|
|
410
|
+
return jsonResult({
|
|
411
|
+
filePath: target,
|
|
412
|
+
bytes: data.length,
|
|
413
|
+
filename,
|
|
414
|
+
mimeType,
|
|
415
|
+
durationSec: rec.durationSec ?? null,
|
|
416
|
+
audioUrl: url,
|
|
417
|
+
note:
|
|
418
|
+
'Saved on the machine running the MCP server. Run your own speech-to-text on this file if you want ' +
|
|
419
|
+
'to avoid spending Rootr AI credits.',
|
|
420
|
+
});
|
|
421
|
+
} catch (err) {
|
|
422
|
+
return errorResult(err);
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
);
|
|
426
|
+
}
|
package/lib/mcp-tools/shared.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { loadConfig } from '../config.js';
|
|
1
|
+
import { loadConfig, keyForWorkspace } from '../config.js';
|
|
2
2
|
import { RootrClient, RootrApiError } from '../client.js';
|
|
3
|
+
import { currentClientOverride } from '../context.js';
|
|
3
4
|
|
|
4
5
|
export function textResult(text) {
|
|
5
6
|
return { content: [{ type: 'text', text }] };
|
|
@@ -23,9 +24,22 @@ export function jsonResult(value) {
|
|
|
23
24
|
* that every workspace-scoped MCP tool accepts.
|
|
24
25
|
*/
|
|
25
26
|
export function makeClient(workspaceOverride) {
|
|
27
|
+
const hosted = currentClientOverride();
|
|
28
|
+
if (hosted) {
|
|
29
|
+
// Hosted MCP requests are locked to the workspace granted at OAuth
|
|
30
|
+
// consent time — silently ignoring a caller-supplied workspace here
|
|
31
|
+
// would be a scoping bug, so only honor an override that matches.
|
|
32
|
+
if (workspaceOverride && workspaceOverride !== hosted.workspace) {
|
|
33
|
+
throw new RootrApiError('이 연결은 하나의 워크스페이스에만 연결되어 있습니다.', {
|
|
34
|
+
hint: '다른 워크스페이스가 필요하면 설정>연동에서 새 원격 커넥터를 발급하세요.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return hosted;
|
|
38
|
+
}
|
|
26
39
|
const cfg = loadConfig();
|
|
27
40
|
const workspace = workspaceOverride || cfg.workspace;
|
|
28
|
-
|
|
41
|
+
// Each workspace may need a different key (see config.js keys[]).
|
|
42
|
+
return new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
/**
|
|
@@ -33,7 +47,8 @@ export function makeClient(workspaceOverride) {
|
|
|
33
47
|
* then ROOTR_WORKSPACE/config. Throws a clear RootrApiError otherwise.
|
|
34
48
|
*/
|
|
35
49
|
export function requireWorkspace(workspaceOverride) {
|
|
36
|
-
const
|
|
50
|
+
const hosted = currentClientOverride();
|
|
51
|
+
const cfg = hosted ? { workspace: hosted.workspace } : loadConfig();
|
|
37
52
|
const workspace = workspaceOverride || cfg.workspace;
|
|
38
53
|
if (!workspace) {
|
|
39
54
|
throw new RootrApiError('워크스페이스가 설정되지 않았습니다.', {
|