rootr-cli 0.19.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 CHANGED
@@ -211,6 +211,11 @@ rootr config # 등록된 키 목록 확인
211
211
  워크스페이스가 바뀌고, 그 워크스페이스를 담당하는 키가 자동으로 선택·기억됩니다. 계정
212
212
  키(PAT)를 쓰면 소속된 어느 워크스페이스로든 바로 전환됩니다.
213
213
 
214
+ MCP 서버를 `ROOTR_WORKSPACE` 환경변수로 띄웠다면(MCP 클라이언트 설정의 `env: {…}`), 재시작할 때마다
215
+ 환경변수가 다시 이깁니다. `rootr_use_workspace` 전환은 그 서버 프로세스가 켜져 있는 동안만 적용되고,
216
+ 전환 응답이 "재시작하면 어느 워크스페이스로 돌아가는지"를 알려 줍니다. 전환을 계속 유지하려면 그 설정의
217
+ `ROOTR_WORKSPACE` 를 바꾸거나 지우세요.
218
+
214
219
  워크스페이스 키는 자기 워크스페이스 밖에서는 열리지 않습니다 — `rootr ws`(=`rootr_workspaces`)
215
220
  목록에 보여도 접근은 안 되는 경우가 있어, 등록된 키가 워크스페이스 키뿐이면 목록에
216
221
  `[등록된 키로는 접근 불가]`를 함께 표시합니다.
@@ -346,7 +351,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
346
351
 
347
352
  ## MCP 서버로 사용하기
348
353
 
349
- `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 178종:
354
+ `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 186종:
350
355
 
351
356
  노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
352
357
  `recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
@@ -380,6 +385,8 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
380
385
  |---|---|
381
386
  | `rootr_upload_attachment` | 로컬 파일(`filePath`) 또는 base64(`base64Data`)를 업로드하고 `{id, url, markdown}` 반환 |
382
387
  | `rootr_attach_image_to_document` | 위 업로드 + 지정 문서에 `rootr_append`로 바로 삽입 (가장 흔한 용법) |
388
+ | `rootr_create_upload_url` | 잠깐(5분) 쓰는 올리기 주소를 받는다 — 파일 바이트를 그 주소에 직접 `PUT`. 큰 파일, 그리고 `filePath` 를 못 쓰는 원격 커넥터용 |
389
+ | `rootr_complete_upload` | `PUT` 이 끝난 뒤 호출 — 올라간 것을 확인하고 `{id, url, markdown}` 반환 |
383
390
 
384
391
  `url`은 `/attachments/{id}/raw` — 로그인 없이 렌더되는 공개 경로라 published(`/p/`) 문서에도
385
392
  그대로 쓸 수 있습니다. CLI에서는 `rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>]`.
package/README.md CHANGED
@@ -11,7 +11,7 @@ citations back to the exact paragraph.
11
11
  *(한국어 문서: [README.ko.md](https://github.com/gwmage/rootr-cli/blob/main/README.ko.md))*
12
12
 
13
13
  - `rootr` CLI — documents, databases, forms, signed documents, publishing, permissions, memory
14
- - `rootr mcp` — stdio MCP server, **178 tools** (see [MCP tools](#mcp-tools))
14
+ - `rootr mcp` — stdio MCP server, **186 tools** (see [MCP tools](#mcp-tools))
15
15
 
16
16
  Plain Node.js ESM, no build step. Node 18+ (uses the built-in `fetch`).
17
17
 
@@ -117,6 +117,11 @@ After that, `rootr use` (and the `rootr_use_workspace` MCP tool) accepts a **nam
117
117
  rootr.io URL**; the key that opens that workspace is selected and remembered automatically. An
118
118
  account key (PAT) switches to any workspace you belong to.
119
119
 
120
+ If the MCP server was started with `ROOTR_WORKSPACE` set (the usual `env: {…}` block in an MCP client
121
+ config), the env var wins again on every restart: a `rootr_use_workspace` switch applies for as long as
122
+ that server process runs, and the reply says which workspace it returns to after a restart/reconnect.
123
+ To make the switch permanent, change (or remove) `ROOTR_WORKSPACE` in that config.
124
+
120
125
  A workspace key does not open anything outside its own workspace. Because of that, `rootr ws`
121
126
  marks entries your registered keys cannot open — **being listed and being reachable are not the
122
127
  same thing**.
@@ -389,7 +394,7 @@ is refused and says when the grace was used.
389
394
 
390
395
  ## MCP tools
391
396
 
392
- `rootr mcp` speaks MCP over stdio. 178 tools:
397
+ `rootr mcp` speaks MCP over stdio. 186 tools:
393
398
 
394
399
  | Group | Count | Tools |
395
400
  |---|---|---|
@@ -408,7 +413,7 @@ is refused and says when the grace was used.
408
413
  | LOG datastores | 5 | create store, update fields, add entries, query, stats |
409
414
  | Templates | 4 | list, apply, save as template, update |
410
415
  | Whiteboards | 3 | create, read and update the shapes & edges scene |
411
- | Attachments | 2 | upload a file, attach an image straight into a document |
416
+ | Attachments | 4 | upload a file, attach an image straight into a document, upload by presigned URL (`rootr_create_upload_url` → PUT the bytes → `rootr_complete_upload`; for large files and the remote connector, where `filePath` is not available) |
412
417
  | Agent memory | 3 | remember, recall, forget |
413
418
  | Webhooks | 3 | list, create (secret shown once), delete |
414
419
  | Visitor booking & Google Calendar | 6 | turn a published table into a booking page, rules, open slots, connect / list / disconnect Google Calendar |
package/bin/rootr.js CHANGED
@@ -5,7 +5,7 @@ import { dirname, join, basename, extname } from 'node:path';
5
5
 
6
6
  import { loadConfig, saveConfig, keyForWorkspace, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
7
7
  import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../lib/credentials.js';
8
- import { RootrClient, RootrApiError } from '../lib/client.js';
8
+ import { RootrClient, RootrApiError, imageMarkdown } from '../lib/client.js';
9
9
  import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
10
10
  import { t as tr, lang } from '../lib/i18n.js';
11
11
 
@@ -576,7 +576,7 @@ async function cmdAttach(args) {
576
576
 
577
577
  const { id } = await client.uploadAttachment(workspace, { filename, mimeType, nodeId, data });
578
578
  const url = client.attachmentRawUrl(id);
579
- const markdown = `![${args.alt || filename}](${url})`;
579
+ const markdown = imageMarkdown(args.alt || filename, url);
580
580
 
581
581
  if (nodeId) {
582
582
  const result = await client.appendDocument(nodeId, { content: markdown, underHeading: args.heading });
package/lib/client.js CHANGED
@@ -1652,6 +1652,24 @@ export class RootrClient {
1652
1652
  return `${this.publicBaseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
1653
1653
  }
1654
1654
 
1655
+ /**
1656
+ * ISSUE-222 — presigned-URL upload (option 1 from the feature request): the
1657
+ * caller PUTs the file bytes straight to S3 from wherever it has local disk
1658
+ * access, instead of inlining them into an MCP tool call as base64Data.
1659
+ * Mirrors the editor's presign→PUT→complete flow (`/attachments/presign`).
1660
+ */
1661
+ async presignAttachment(workspaceId, { filename, mimeType, size, nodeId } = {}) {
1662
+ return this.request('/attachments/presign', {
1663
+ method: 'POST',
1664
+ body: { workspaceId, filename, mimeType, size, nodeId },
1665
+ });
1666
+ }
1667
+
1668
+ /** Marks a presigned attachment READY after the bytes have been PUT to S3. */
1669
+ async completeAttachment(attachmentId) {
1670
+ return this.request(`/attachments/${encodeURIComponent(attachmentId)}/complete`, { method: 'POST' });
1671
+ }
1672
+
1655
1673
  // ---- Spreadsheets ---------------------------------------------------------
1656
1674
 
1657
1675
  async listSpreadsheets(workspace) {
@@ -2043,3 +2061,22 @@ export class RootrClient {
2043
2061
  }
2044
2062
 
2045
2063
  }
2064
+
2065
+ /**
2066
+ * `![alt](url)` — 문서에 그대로 붙여 넣을 그림 한 줄.
2067
+ *
2068
+ * alt 자리에는 파일 이름이 그대로 들어가는데, 이름에 `]` 가 있으면 거기서 대괄호가 닫혀 그림이
2069
+ * 아니라 깨진 글자가 되고(실측: 편집기에서 그림이 안 뜬다), `![가짜](http://…)` 같은 이름은
2070
+ * 남의 주소의 그림을 문서에 끼워 넣는다. 역슬래시로 피하는 방법은 편집기의 해석기가 역슬래시를
2071
+ * 글자 그대로 보여 줘서 쓰지 않는다 — 대괄호는 소괄호로, 역슬래시는 빗금으로, 줄바꿈·탭은
2072
+ * 빈칸으로 바꾼다(alt 는 설명 글이라 뜻이 달라지지 않는다). 저장되는 파일 이름은 건드리지 않는다.
2073
+ */
2074
+ export function imageMarkdown(alt, url) {
2075
+ const safe = String(alt ?? '')
2076
+ .replace(/\[/g, '(')
2077
+ .replace(/\]/g, ')')
2078
+ .replace(/\\/g, '/')
2079
+ .replace(/[\r\n\t]+/g, ' ')
2080
+ .trim();
2081
+ return `![${safe}](${url})`;
2082
+ }
@@ -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
  }
@@ -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 { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../credentials.js';
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
@@ -49,6 +68,8 @@ export function registerWorkspaceTools(server) {
49
68
  'reconfiguration, no restart. With an account key (PAT) you can switch to any workspace you belong ' +
50
69
  'to; if separate workspace keys are configured, the one that covers the target is selected and ' +
51
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. ' +
52
73
  '(A hosted/remote connector is bound to a single workspace at consent time and cannot switch.)',
53
74
  annotations: { readOnlyHint: false, destructiveHint: false },
54
75
  inputSchema: {
@@ -91,6 +112,9 @@ export function registerWorkspaceTools(server) {
91
112
  // process, same request loop) picks up the switch immediately.
92
113
  if (process.env.ROOTR_WORKSPACE) process.env.ROOTR_WORKSPACE = match.workspace.id;
93
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);
94
118
  process.env.ROOTR_API_KEY = match.candidate.apiKey;
95
119
  }
96
120
  // Verify the switch actually took before reporting success — never
@@ -107,7 +131,23 @@ export function registerWorkspaceTools(server) {
107
131
  );
108
132
  }
109
133
  const name = match.workspace.name || match.workspace.slug || '';
110
- return textResult(`활성 워크스페이스를 "${name}" (${match.workspace.id}) 로 바꿨습니다. 이후 도구 호출은 여기에 적용됩니다.`);
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
+ );
111
151
  } catch (err) {
112
152
  return errorResult(err);
113
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Official Rootr CLI + MCP server — read, write, edit and search Rootr documents from the shell or from an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {