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.
@@ -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 — `rootr config --add-key`/`--api-key` writes to
159
- // ~/.rootr/config.json from a SEPARATE process. If this (MCP server)
160
- // process was started with ROOTR_API_KEY set, config.js makes that env
161
- // var win over anything just written to the file (see its ENV-WINS
162
- // comment) — so the fix this message recommends silently does nothing
163
- // until the server itself is restarted. Say so instead of pointing at a
164
- // remedy that cannot take effect.
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 restartNote = envPinned
167
- ? tr(
168
- '\n주의: 이 서버 프로세스는 ROOTR_API_KEY 환경변수로 키가 고정되어 있어, 위 명령으로 키를 추가해도 서버를 ' +
169
- '재시작해야 반영됩니다 (같은 프로세스가 계속 돌아가는 동안은 파일에 새로 쓴 키를 읽지 않습니다).',
170
- '\nNote: this server process has ROOTR_API_KEY pinned via env var, so adding a key with the command above ' +
171
- 'only takes effect after the server is restarted (it keeps reading the env-pinned key while running).',
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
- '조치 (둘 중 하나, 사용자가 직접):\n' +
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
- 'Fix (either one, done by you):\n' +
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
- if (candidate && candidate.apiKey !== cfg.apiKey) {
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
- assertLocalFilesAllowed(filePath);
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()) throw new RootrApiError('base64Data 가 필요합니다 (원격 커넥터에서는 filePath 를 쓸 수 없습니다).');
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 (![alt](url)). 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: `![${filename}](${url})`,
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 = `![${alt || filename}](${url})`;
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
  }