rootr-cli 0.19.0 → 0.22.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 +45 -3
- package/README.md +41 -6
- package/bin/rootr.js +151 -8
- package/lib/client.js +100 -2
- package/lib/credentials.js +63 -24
- package/lib/mcp-tools/attachments.js +124 -6
- package/lib/mcp-tools/databases.js +289 -4
- package/lib/mcp-tools/forms.js +6 -1
- package/lib/mcp-tools/pages.js +29 -4
- package/lib/mcp-tools/workspaces.js +47 -2
- package/package.json +1 -1
package/lib/credentials.js
CHANGED
|
@@ -15,6 +15,7 @@ import { t as tr } from './i18n.js';
|
|
|
15
15
|
export function candidateClients(cfg = loadConfig()) {
|
|
16
16
|
const out = [];
|
|
17
17
|
const seen = new Set();
|
|
18
|
+
// (sessionKeys — 아래 rememberSessionKey 참고 — 는 맨 뒤에 붙는다.)
|
|
18
19
|
if (cfg.apiKey) {
|
|
19
20
|
seen.add(cfg.apiKey);
|
|
20
21
|
out.push({ apiKey: cfg.apiKey, label: 'default', client: new RootrClient({ ...cfg, apiKey: cfg.apiKey }) });
|
|
@@ -24,9 +25,37 @@ export function candidateClients(cfg = loadConfig()) {
|
|
|
24
25
|
seen.add(k.apiKey);
|
|
25
26
|
out.push({ apiKey: k.apiKey, label: k.label || k.workspace || 'key', client: new RootrClient({ ...cfg, apiKey: k.apiKey }) });
|
|
26
27
|
}
|
|
28
|
+
for (const apiKey of sessionKeys) {
|
|
29
|
+
if (seen.has(apiKey)) continue;
|
|
30
|
+
seen.add(apiKey);
|
|
31
|
+
out.push({ apiKey, label: 'session', ephemeral: true, client: new RootrClient({ ...cfg, apiKey }) });
|
|
32
|
+
}
|
|
27
33
|
return out;
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/**
|
|
37
|
+
* ISSUE-221 — keys this (long-running, stdio MCP) process was handed through
|
|
38
|
+
* the ROOTR_API_KEY env var and has since switched away from.
|
|
39
|
+
*
|
|
40
|
+
* rootr_use_workspace swaps process.env.ROOTR_API_KEY to the key that opens
|
|
41
|
+
* the target workspace. If the key it replaces lived only in the environment
|
|
42
|
+
* (the usual MCP client config: `env: { ROOTR_API_KEY, ROOTR_WORKSPACE }`),
|
|
43
|
+
* nothing else remembers it — so after A → B the server could no longer open
|
|
44
|
+
* A again ("none of the registered keys opens it"), although it was started
|
|
45
|
+
* with exactly that key. Keep such keys for the life of the process so the way
|
|
46
|
+
* back stays open.
|
|
47
|
+
*
|
|
48
|
+
* In memory only: an env-supplied secret is never written to
|
|
49
|
+
* ~/.rootr/config.json behind the user's back (rememberWorkspace skips
|
|
50
|
+
* `ephemeral` candidates). Never called on the hosted path — that one returns
|
|
51
|
+
* before any switch happens, so tenants cannot see each other's keys here.
|
|
52
|
+
*/
|
|
53
|
+
const sessionKeys = [];
|
|
54
|
+
|
|
55
|
+
export function rememberSessionKey(apiKey) {
|
|
56
|
+
if (apiKey && !sessionKeys.includes(apiKey)) sessionKeys.push(apiKey);
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
/**
|
|
31
60
|
* Collect every workspace the registered keys can see, and which of them a
|
|
32
61
|
* key can actually open — listing is not a capability probe: a workspace key
|
|
@@ -155,22 +184,36 @@ export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
|
|
|
155
184
|
|
|
156
185
|
if (target) {
|
|
157
186
|
const scoped = lastRejection && lastRejection.status === 403;
|
|
158
|
-
// ISSUE-172 —
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
187
|
+
// ISSUE-172/221 — when this (MCP server) process was started with
|
|
188
|
+
// ROOTR_API_KEY set, config.js makes that env var win over the DEFAULT key
|
|
189
|
+
// in ~/.rootr/config.json (see its ENV-WINS comment), now and after every
|
|
190
|
+
// restart. So for such a server the usual first remedy (`rootr config
|
|
191
|
+
// --api-key <PAT>`) never takes effect — the PAT has to go into the MCP
|
|
192
|
+
// server's own env config. Extra keys (`--add-key`) are different: the
|
|
193
|
+
// file is re-read on every call and they are tried as candidates, so that
|
|
194
|
+
// remedy works at once, without a restart. (Measured 2026-10-09 against a
|
|
195
|
+
// real server: the earlier note had both of these the wrong way round.)
|
|
165
196
|
const envPinned = !!process.env.ROOTR_API_KEY;
|
|
166
|
-
const
|
|
167
|
-
?
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
)
|
|
173
|
-
: ''
|
|
197
|
+
const remediesKo = envPinned
|
|
198
|
+
? '조치 (둘 중 하나, 사용자가 직접):\n' +
|
|
199
|
+
'1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → MCP 서버 설정의 ROOTR_API_KEY 환경변수를 그 키로 바꾸고 ' +
|
|
200
|
+
'서버를 재시작 (이 서버는 ROOTR_API_KEY 환경변수의 키를 쓰므로, `rootr config --api-key` 로 파일에 적은 키는 ' +
|
|
201
|
+
'재시작해도 쓰이지 않습니다)\n' +
|
|
202
|
+
`2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\` ` +
|
|
203
|
+
'(재시작 없이, 등록한 뒤 바로 다시 전환하면 됩니다)'
|
|
204
|
+
: '조치 (둘 중 하나, 사용자가 직접):\n' +
|
|
205
|
+
'1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → `rootr config --api-key <키>` (모든 워크스페이스에 통함)\n' +
|
|
206
|
+
`2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\``;
|
|
207
|
+
const remediesEn = envPinned
|
|
208
|
+
? 'Fix (either one, done by you):\n' +
|
|
209
|
+
'1) Create an account key (PAT) under rootr.io > Settings > API keys → put it in the ROOTR_API_KEY env var of ' +
|
|
210
|
+
'the MCP server config and restart the server (this server uses the key from ROOTR_API_KEY, so a key written ' +
|
|
211
|
+
'with `rootr config --api-key` is not used, even after a restart)\n' +
|
|
212
|
+
`2) Create a key for that workspace → \`rootr config --add-key <key> --workspace ${target.id}\` ` +
|
|
213
|
+
'(no restart needed — switch again right after registering it)'
|
|
214
|
+
: 'Fix (either one, done by you):\n' +
|
|
215
|
+
'1) Create an account key (PAT) under rootr.io > Settings > API keys → `rootr config --api-key <key>` (works everywhere)\n' +
|
|
216
|
+
`2) Create a key for that workspace → \`rootr config --add-key <key> --workspace ${target.id}\``;
|
|
174
217
|
return {
|
|
175
218
|
error: tr(
|
|
176
219
|
`"${target.name || target.id}" (${target.id}) 는 접근 권한 목록에는 있지만, 등록된 키로는 열 수 없습니다` +
|
|
@@ -178,19 +221,13 @@ export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
|
|
|
178
221
|
(scoped
|
|
179
222
|
? '원인: 지금 등록된 키가 다른 워크스페이스 전용 키입니다 (워크스페이스 키는 자기 워크스페이스 밖에서 403).\n'
|
|
180
223
|
: `원인: ${lastRejection ? lastRejection.message : '알 수 없음'}\n`) +
|
|
181
|
-
|
|
182
|
-
'1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → `rootr config --api-key <키>` (모든 워크스페이스에 통함)\n' +
|
|
183
|
-
`2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\`` +
|
|
184
|
-
restartNote,
|
|
224
|
+
remediesKo,
|
|
185
225
|
`"${target.name || target.id}" (${target.id}) is on your access list, but none of the registered keys opens it` +
|
|
186
226
|
`${target.role ? ` (your role: ${target.role})` : ''}.\n` +
|
|
187
227
|
(scoped
|
|
188
228
|
? 'Cause: the registered key belongs to a different workspace (a workspace key gets 403 outside its own).\n'
|
|
189
229
|
: `Cause: ${lastRejection ? lastRejection.message : 'unknown'}\n`) +
|
|
190
|
-
|
|
191
|
-
'1) Create an account key (PAT) under rootr.io > Settings > API keys → `rootr config --api-key <key>` (works everywhere)\n' +
|
|
192
|
-
`2) Create a key for that workspace → \`rootr config --add-key <key> --workspace ${target.id}\`` +
|
|
193
|
-
restartNote,
|
|
230
|
+
remediesEn,
|
|
194
231
|
),
|
|
195
232
|
};
|
|
196
233
|
}
|
|
@@ -222,7 +259,9 @@ export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
|
|
|
222
259
|
/** Persist the switch: remember the workspace and which key covers it. */
|
|
223
260
|
export function rememberWorkspace({ workspace, candidate }, cfg = loadConfig()) {
|
|
224
261
|
saveConfig({ workspace: workspace.id });
|
|
225
|
-
|
|
262
|
+
// `ephemeral` = a key that only ever lived in this process's environment (see
|
|
263
|
+
// rememberSessionKey) — it stays out of the config file.
|
|
264
|
+
if (candidate && candidate.apiKey !== cfg.apiKey && !candidate.ephemeral) {
|
|
226
265
|
upsertKey({ apiKey: candidate.apiKey, workspace: workspace.id, label: candidate.label });
|
|
227
266
|
}
|
|
228
267
|
}
|
|
@@ -14,18 +14,41 @@ import {
|
|
|
14
14
|
localFileArg,
|
|
15
15
|
} from './shared.js';
|
|
16
16
|
import { resolveTarget, requireResolved } from '../resolve.js';
|
|
17
|
-
import { RootrApiError } from '../client.js';
|
|
17
|
+
import { RootrApiError, imageMarkdown } from '../client.js';
|
|
18
|
+
|
|
19
|
+
// ISSUE-222 — 원격 커넥터에서 filePath 를 쓰려다 막힌 호출자가 막다른 길에 서지 않게, 거절하면서
|
|
20
|
+
// 새 길(주소를 받아 직접 올리기)을 같이 알려 준다. 큰 파일은 base64Data 로도 못 싣는다.
|
|
21
|
+
const PRESIGN_HINT =
|
|
22
|
+
'큰 파일은 rootr_create_upload_url 로 올리기 주소를 받아 파일 바이트를 그 주소에 직접 PUT 한 뒤 ' +
|
|
23
|
+
'rootr_complete_upload 를 부르세요 (파일 내용이 도구 호출에 실리지 않습니다).';
|
|
18
24
|
|
|
19
25
|
/** Reads file bytes from a local path or a base64 string; exactly one must be given. */
|
|
20
26
|
function readAttachmentBytes({ filePath, base64Data }) {
|
|
21
27
|
// SEC-MCP-FILES — 원격 커넥터에서는 서버 파일을 읽게 되므로 읽기 전에 거절한다.
|
|
22
|
-
|
|
28
|
+
if (isHostedMcp() && filePath) {
|
|
29
|
+
throw new RootrApiError('원격 커넥터에서는 filePath 를 쓸 수 없습니다.', {
|
|
30
|
+
hint:
|
|
31
|
+
'filePath 는 MCP 서버가 도는 기계의 파일을 가리킵니다. 원격 커넥터에서는 그 기계가 Rootr 서버라 ' +
|
|
32
|
+
`쓸 수 없습니다 — 작은 파일은 내용을 base64Data 로 보내세요. ${PRESIGN_HINT}`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
assertLocalFilesAllowed(filePath); // 위에서 걸렀지만 두 겹으로 둔다
|
|
23
36
|
if (filePath) return readFileSync(filePath);
|
|
24
37
|
if (base64Data) return Buffer.from(base64Data, 'base64');
|
|
25
|
-
if (isHostedMcp())
|
|
38
|
+
if (isHostedMcp()) {
|
|
39
|
+
throw new RootrApiError('base64Data 가 필요합니다 (원격 커넥터에서는 filePath 를 쓸 수 없습니다).', {
|
|
40
|
+
hint: PRESIGN_HINT,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
26
43
|
throw new RootrApiError('filePath 또는 base64Data 중 하나가 필요합니다.');
|
|
27
44
|
}
|
|
28
45
|
|
|
46
|
+
/** 첨부 id 모양(UUID). 주소 칸에 들어가는 값이라 다른 모양은 서버에 보내지 않는다. */
|
|
47
|
+
const ATTACHMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
48
|
+
|
|
49
|
+
/** 빈 값·공백뿐인 값은 받지 않는다(값 자체는 건드리지 않는다 — 앞뒤 공백도 이름의 일부일 수 있다). */
|
|
50
|
+
const nonBlank = (what) => [(v) => typeof v === 'string' && v.trim().length > 0, `${what} must not be empty`];
|
|
51
|
+
|
|
29
52
|
// 등록 시점(원격이면 요청마다)에 만든다 — 원격 커넥터에서는 filePath 칸이 스키마에서 빠진다.
|
|
30
53
|
const buildUploadInputSchema = () => ({
|
|
31
54
|
filename: z.string().describe('File name to store, e.g. "screenshot.png"'),
|
|
@@ -57,7 +80,9 @@ export function registerAttachmentTools(server) {
|
|
|
57
80
|
'{ id, url, markdown, filename, mimeType, size } — `url` is the public, unauthenticated ' +
|
|
58
81
|
'/attachments/{id}/raw link (renders in <img> tags and on published /p/ documents without login), and ' +
|
|
59
82
|
'`markdown` is ready to paste into a document body (). For the common case of inserting an ' +
|
|
60
|
-
'image straight into a document, prefer rootr_attach_image_to_document instead.'
|
|
83
|
+
'image straight into a document, prefer rootr_attach_image_to_document instead. For a large file (or ' +
|
|
84
|
+
'over the remote connector, where filePath is not available), use rootr_create_upload_url → PUT → ' +
|
|
85
|
+
'rootr_complete_upload instead of inlining the bytes as base64Data.',
|
|
61
86
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
62
87
|
inputSchema: uploadInputSchema,
|
|
63
88
|
},
|
|
@@ -75,7 +100,7 @@ export function registerAttachmentTools(server) {
|
|
|
75
100
|
return jsonResult({
|
|
76
101
|
id,
|
|
77
102
|
url,
|
|
78
|
-
markdown:
|
|
103
|
+
markdown: imageMarkdown(filename, url),
|
|
79
104
|
filename,
|
|
80
105
|
mimeType,
|
|
81
106
|
size: data.length,
|
|
@@ -120,7 +145,7 @@ export function registerAttachmentTools(server) {
|
|
|
120
145
|
const data = readAttachmentBytes({ filePath, base64Data });
|
|
121
146
|
const { id } = await client.uploadAttachment(ws, { filename, mimeType, nodeId, data });
|
|
122
147
|
const url = client.attachmentRawUrl(id);
|
|
123
|
-
const markdown =
|
|
148
|
+
const markdown = imageMarkdown(alt || filename, url);
|
|
124
149
|
const result = await client.appendDocument(nodeId, { content: markdown, underHeading });
|
|
125
150
|
return jsonResult({ id, url, markdown, appended: true, etag: result.etag });
|
|
126
151
|
} catch (err) {
|
|
@@ -128,4 +153,97 @@ export function registerAttachmentTools(server) {
|
|
|
128
153
|
}
|
|
129
154
|
},
|
|
130
155
|
);
|
|
156
|
+
|
|
157
|
+
// ISSUE-222 — rootr_upload_attachment/rootr_attach_image_to_document require
|
|
158
|
+
// the full file as base64Data over the remote connector (it can't read
|
|
159
|
+
// filePath), which breaks down for larger images. This gives the caller a
|
|
160
|
+
// short-lived presigned S3 URL instead: PUT the bytes there directly (works
|
|
161
|
+
// from anywhere with local disk access, no MCP message-size cost), then call
|
|
162
|
+
// rootr_complete_upload with the returned id.
|
|
163
|
+
server.registerTool(
|
|
164
|
+
'rootr_create_upload_url',
|
|
165
|
+
{
|
|
166
|
+
title: 'Get a presigned URL to upload a file/image as a Rootr attachment',
|
|
167
|
+
description:
|
|
168
|
+
'Get a short-lived presigned URL for uploading a file (screenshot, photo, diagram export, ...) to the ' +
|
|
169
|
+
'Rootr (루터) workspace as an attachment. PUT the raw file bytes to `uploadUrl` yourself (Content-Type ' +
|
|
170
|
+
'must match the `mimeType` you gave here) — this is the preferred path for larger files, since no file ' +
|
|
171
|
+
'bytes travel through this tool call. After the PUT succeeds, call rootr_complete_upload with the ' +
|
|
172
|
+
'returned `id` to mark the attachment ready and get its `url`/`markdown`.',
|
|
173
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
174
|
+
inputSchema: {
|
|
175
|
+
filename: z.string().refine(...nonBlank('filename')).describe('File name to store, e.g. "diagram.png"'),
|
|
176
|
+
mimeType: z.string().refine(...nonBlank('mimeType')).describe('MIME type, e.g. "image/png"'),
|
|
177
|
+
size: z
|
|
178
|
+
.number()
|
|
179
|
+
.int()
|
|
180
|
+
.positive()
|
|
181
|
+
.describe('File size in bytes (max 75MB). The stored size is whatever you actually PUT to uploadUrl.'),
|
|
182
|
+
nodeId: z.string().optional().describe(`Document/node id${NODE_TARGET_HINT} to associate the attachment with (permission-checked)`),
|
|
183
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
async ({ filename, mimeType, size, nodeId, workspace }) => {
|
|
187
|
+
try {
|
|
188
|
+
const ws = requireWorkspace(workspace);
|
|
189
|
+
const client = makeClient(ws);
|
|
190
|
+
nodeId = await resolveOptionalNodeId(client, nodeId);
|
|
191
|
+
const { id, uploadUrl, expiresIn } = await client.presignAttachment(ws, { filename, mimeType, size, nodeId });
|
|
192
|
+
return jsonResult({
|
|
193
|
+
id,
|
|
194
|
+
uploadUrl,
|
|
195
|
+
expiresIn,
|
|
196
|
+
instructions: `PUT the raw file bytes to uploadUrl with header Content-Type: ${mimeType}, then call rootr_complete_upload with id "${id}".`,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return errorResult(err);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
server.registerTool(
|
|
205
|
+
'rootr_complete_upload',
|
|
206
|
+
{
|
|
207
|
+
title: 'Finish a presigned attachment upload',
|
|
208
|
+
description:
|
|
209
|
+
'Call this after PUTting file bytes to the `uploadUrl` from rootr_create_upload_url. Verifies the ' +
|
|
210
|
+
'upload landed in storage and marks the attachment READY. Returns { id, url, markdown } — `url` is the ' +
|
|
211
|
+
'public /attachments/{id}/raw link and `markdown` is ready to paste into a document body.',
|
|
212
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
213
|
+
inputSchema: {
|
|
214
|
+
// id 는 주소의 한 칸으로 들어간다. `../workspaces` 같은 값은 인코딩해 보내도 운영의 프록시가
|
|
215
|
+
// 풀어서 다른 길(/v1/workspaces/complete)로 보내고, 호출한 쪽은 "Cannot POST …" 라는 날것의
|
|
216
|
+
// 라우팅 오류를 받는다(운영 실측 2026-10-09). 받은 id 모양이 아니면 보내기 전에 거절한다.
|
|
217
|
+
id: z
|
|
218
|
+
.string()
|
|
219
|
+
.refine(...nonBlank('id'))
|
|
220
|
+
.refine(
|
|
221
|
+
(v) => typeof v !== 'string' || v.trim().length === 0 || ATTACHMENT_ID.test(v),
|
|
222
|
+
'id must be the attachment id returned by rootr_create_upload_url (a UUID) — not a path, URL or filename',
|
|
223
|
+
)
|
|
224
|
+
.describe('The attachment id returned by rootr_create_upload_url'),
|
|
225
|
+
filename: z.string().optional().describe('Filename to use in the markdown alt text (defaults to the one given at presign time)'),
|
|
226
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
async ({ id, filename, workspace }) => {
|
|
230
|
+
try {
|
|
231
|
+
const ws = requireWorkspace(workspace);
|
|
232
|
+
const client = makeClient(ws);
|
|
233
|
+
const attachment = await client.completeAttachment(id);
|
|
234
|
+
const url = client.attachmentRawUrl(id);
|
|
235
|
+
const name = filename || attachment.filename;
|
|
236
|
+
return jsonResult({
|
|
237
|
+
id,
|
|
238
|
+
url,
|
|
239
|
+
markdown: imageMarkdown(name, url),
|
|
240
|
+
filename: name,
|
|
241
|
+
mimeType: attachment.mimeType,
|
|
242
|
+
size: attachment.size,
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
return errorResult(err);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
);
|
|
131
249
|
}
|