rootr-cli 0.17.3 → 0.19.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/lib/client.js CHANGED
@@ -97,10 +97,17 @@ function hintFor(status, body) {
97
97
  }
98
98
 
99
99
  export class RootrClient {
100
- constructor({ apiKey, workspace, baseUrl, defaultHeaders }) {
100
+ constructor({ apiKey, workspace, baseUrl, publicBaseUrl, defaultHeaders }) {
101
101
  this.apiKey = apiKey;
102
102
  this.workspace = workspace;
103
103
  this.baseUrl = (baseUrl || '').replace(/\/+$/, '');
104
+ /**
105
+ * ISSUE-61 — 사람·문서에 건네는 주소(첨부 `/raw` 등)의 뿌리. 로컬 CLI 는 baseUrl 이 곧 밖에서
106
+ * 보이는 주소라 비워 둔다. 원격 커넥터는 API 를 127.0.0.1 로 되부르므로(baseUrl) 그 주소를
107
+ * 그대로 문서에 넣으면 사람 화면에서 그림이 깨진다 — 호스티드 전송 계층이 공개 주소를 따로 넘긴다.
108
+ * 요청을 보내는 데는 절대 쓰지 않는다(그건 언제나 baseUrl).
109
+ */
110
+ this.publicBaseUrl = (publicBaseUrl || '').replace(/\/+$/, '') || this.baseUrl;
104
111
  /** Sent on every request — the hosted transport forwards Accept-Language
105
112
  * here so API error text comes back in the caller's language. */
106
113
  this.defaultHeaders = defaultHeaders || {};
@@ -247,6 +254,32 @@ export class RootrClient {
247
254
  }
248
255
  }
249
256
 
257
+ /** Resolve a published `/p/{slug}` public URL to a node in the active workspace. */
258
+ async getNodeBySlug(slug) {
259
+ this._requireWorkspace();
260
+ try {
261
+ const data = await this.request(
262
+ `/workspaces/${encodeURIComponent(this.workspace)}/nodes/by-slug?slug=${encodeURIComponent(slug)}`,
263
+ );
264
+ return data;
265
+ } catch (err) {
266
+ if (err instanceof RootrApiError && err.status === 404) return null;
267
+ throw err;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * ISSUE-127 — 공개 발행본을 읽는다: `/p/{slug}` 화면이 받는 것과 같은 응답(`GET /public/docs/:slug`).
273
+ * 로그인 없이 누구나 읽는 경로라 키가 무엇이든 더 열리는 것이 없다 — 발행한 판만 주고, 발행을 내렸거나
274
+ * 만료·삭제된 것은 404, 비밀번호가 걸린 것은 401(`PUBLIC_DOC_PASSWORD`). 통과증(`pass`)은 일부러 받지 않는다.
275
+ */
276
+ async getPublicDocument(slug) {
277
+ // ISSUE-127 — 이 읽기는 요금제 조회 한도에 1회로 센다(내 문서 읽기와 같다). 계정 키(PAT)는 워크스페이스에 묶여 있지 않아
278
+ // 서버가 어느 워크스페이스의 한도로 셀지 모른다 — 지금 연결된 워크스페이스를 알려 준다(워크스페이스 키는 서버가 키에서 안다).
279
+ const qs = this.workspace ? `?workspaceId=${encodeURIComponent(this.workspace)}` : '';
280
+ return this.request(`/public/docs/${encodeURIComponent(slug)}${qs}`);
281
+ }
282
+
250
283
  async createNode({ type = 'DOCUMENT', path, createParents = true, autoRename = false, content }) {
251
284
  this._requireWorkspace();
252
285
  return this.request(`/workspaces/${encodeURIComponent(this.workspace)}/nodes`, {
@@ -307,10 +340,29 @@ export class RootrClient {
307
340
  returnResponse: true,
308
341
  });
309
342
  const etag = res.headers.get('etag') || res.headers.get('ETag') || undefined;
310
- return { etag };
343
+ // ISSUE-105 — 서버가 쓰기 응답에 실은 알림(HTML 주석이 저장 때 사라졌다 등)을 버리지 않는다.
344
+ let warnings;
345
+ try {
346
+ const text = await res.text();
347
+ warnings = text ? JSON.parse(text).warnings : undefined;
348
+ } catch {
349
+ warnings = undefined;
350
+ }
351
+ return { etag, ...(Array.isArray(warnings) && warnings.length ? { warnings } : {}) };
311
352
  }
312
353
 
313
354
  /** Safe, conflict-free append. */
355
+ /**
356
+ * EMBED-API — 문서에 노드(표·시트·발표자료…) 하나를 끼워 넣는다. `node` 는 id 든 경로든 된다
357
+ * (서버가 id 로 바꿔 `[[embed:<id>]]` 한 줄을 붙인다).
358
+ */
359
+ async embedNode(documentId, { node, underHeading, createIfMissing } = {}) {
360
+ return this.request(`/documents/${encodeURIComponent(documentId)}/embeds`, {
361
+ method: 'POST',
362
+ body: { node, underHeading, createIfMissing },
363
+ });
364
+ }
365
+
314
366
  async appendDocument(id, { content, underHeading, createIfMissing } = {}) {
315
367
  const res = await this.request(`/documents/${encodeURIComponent(id)}/append`, {
316
368
  method: 'POST',
@@ -458,6 +510,71 @@ export class RootrClient {
458
510
  return this.request(`/nodes/${encodeURIComponent(nodeId)}/public-status`);
459
511
  }
460
512
 
513
+ // ── BOOKING — 공개한 표에서 방문자가 일정을 잡게 한다 ──────────────────
514
+ //
515
+ // 예약은 **공개 주소 위에 서는 기능**이다. 그래서 켜기 전에 표가 발행돼 있어야 하고,
516
+ // 안 돼 있으면 서버가 "먼저 표를 웹에 공개해 주세요"로 거절한다.
517
+
518
+ /** 예약 설정(규칙·확정 방식·물어볼 칸·구글 캘린더)과 연결 상태를 본다. */
519
+ async getBooking(nodeId) {
520
+ return this.request(`/nodes/${encodeURIComponent(nodeId)}/booking`);
521
+ }
522
+
523
+ /**
524
+ * 예약 설정을 저장한다. 보낸 칸만 바뀌고 나머지는 그대로다.
525
+ * 날짜·메일 칸이 없으면 서버가 만들어 준다(승인제면 상태 칸까지).
526
+ */
527
+ async setBooking(nodeId, settings = {}) {
528
+ return this.request(`/nodes/${encodeURIComponent(nodeId)}/booking`, {
529
+ method: 'PUT',
530
+ body: settings,
531
+ });
532
+ }
533
+
534
+ /**
535
+ * 방문자에게 보이는 **빈 시간**. 공개 주소(slug)로 부르는 로그인 없는 길이라,
536
+ * 주인이 보는 것과 방문자가 보는 것이 같은지 여기서 바로 확인할 수 있다.
537
+ */
538
+ async bookingSlots(slug, { from, to, pass, type } = {}) {
539
+ const q = new URLSearchParams();
540
+ if (from) q.set('from', from);
541
+ if (to) q.set('to', to);
542
+ if (pass) q.set('pass', pass);
543
+ // 약속 종류마다 길이가 달라 빈 시간도 다르다 — 안 주면 첫 종류로 본다.
544
+ if (type) q.set('type', type);
545
+ return this.request(`/public/nodes/${encodeURIComponent(slug)}/booking/slots?${q.toString()}`);
546
+ }
547
+
548
+ // ── BOOKING-GCAL — 구글 캘린더 연결(사람 한 명의 것) ───────────────────
549
+
550
+ /** 내가 연결해 둔 구글 계정들. `available:false`면 서버에 구글 설정이 아직 없다. */
551
+ async googleCalendarConnections() {
552
+ return this.request('/integrations/google-calendar/connections');
553
+ }
554
+
555
+ /**
556
+ * 구글 동의 화면 주소를 받는다. **브라우저에서 한 번은 허용해야** 연결이 된다 —
557
+ * 구글이 그렇게만 허락한다. 허용하고 나면 그 뒤로는 명령줄에서 다 된다.
558
+ */
559
+ async googleCalendarAuthorizeUrl(returnTo = '/') {
560
+ return this.request('/integrations/google-calendar/authorize', {
561
+ method: 'POST',
562
+ body: { returnTo },
563
+ });
564
+ }
565
+
566
+ /** 그 연결로 볼 수 있는 캘린더들(막을 것·적을 것을 여기서 고른다). */
567
+ async googleCalendars(connectionId) {
568
+ return this.request(`/integrations/google-calendar/connections/${encodeURIComponent(connectionId)}/calendars`);
569
+ }
570
+
571
+ /** 연결을 끊는다. 구글 쪽 권한도 같이 거둔다. */
572
+ async disconnectGoogleCalendar(connectionId) {
573
+ return this.request(`/integrations/google-calendar/connections/${encodeURIComponent(connectionId)}`, {
574
+ method: 'DELETE',
575
+ });
576
+ }
577
+
461
578
  // ── DB-PERM-001 / DB-WATCH — 표에 거는 자물쇠와 알림 규칙 ──────────────
462
579
  //
463
580
  // 노션에 없는 것들이라, 에이전트가 표를 만들어 놓고도 "지원서는 서로 못 보게" 같은
@@ -511,6 +628,65 @@ export class RootrClient {
511
628
  );
512
629
  }
513
630
 
631
+ // ── DB-AUTO — "값이 이렇게 되면 이 일을 해라" 규칙 ───────────────────────
632
+ //
633
+ // 버튼과 같은 단계 문법을 쓰되 사람이 안 눌러도 돈다. 2026-09-18 클린베테랑 요청
634
+ // 4번: 투입 확정 시 해피콜 3건을 사람이 버튼으로 잡고 있었고, 안 누르면 빠졌다.
635
+
636
+ /** 이 표의 자동 규칙 목록. */
637
+ async listDatabaseAutomations(databaseId) {
638
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/automations`);
639
+ }
640
+
641
+ /** 자동 규칙을 **통째로** 갈아끼운다(MANAGE 필요). 남길 규칙도 같이 보내야 한다. */
642
+ async setDatabaseAutomations(databaseId, automations) {
643
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/automations`, {
644
+ method: 'PUT',
645
+ body: { automations },
646
+ });
647
+ }
648
+
649
+ // ── FORM-MAP — 폼 질문이 표의 어느 칸으로 가는가 ───────────────────────
650
+
651
+ /** 질문마다 어느 칸으로 들어가는지 + 짝 없는 질문(값이 버려진다). */
652
+ async formMapping(formId) {
653
+ return this.request(`/forms/${encodeURIComponent(formId)}/mapping`);
654
+ }
655
+
656
+ /** 질문↔칸 짝을 손으로 정한다. `{ 질문id: 칸id }`, 값이 null이면 자동으로 되돌린다. */
657
+ async setFormMapping(formId, fieldMap) {
658
+ return this.request(`/forms/${encodeURIComponent(formId)}/mapping`, {
659
+ method: 'PUT',
660
+ body: { fieldMap },
661
+ });
662
+ }
663
+
664
+ // ── XLSX-FOLD — 시트 복제형 엑셀을 표 하나로 접기 ──────────────────────
665
+
666
+ /**
667
+ * 시트가 수백 장인 엑셀을 표 한 개로 접어 가져온다(2026-09-18 요청 7번).
668
+ * `dryRun: true`면 아무것도 만들지 않고 어떻게 접히는지만 돌려준다.
669
+ */
670
+ async importFoldedSheets(workspaceId, body) {
671
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/databases/import/sheets`, {
672
+ method: 'POST',
673
+ body,
674
+ });
675
+ }
676
+
677
+ // ── MIGRATION-GRACE — 초기 이관 동안 하루 조회 한도 풀기 ────────────────
678
+
679
+ async startMigrationGrace(workspaceId) {
680
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/billing/migration-grace`, {
681
+ method: 'POST',
682
+ body: {},
683
+ });
684
+ }
685
+
686
+ async migrationGrace(workspaceId) {
687
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/billing/migration-grace`);
688
+ }
689
+
514
690
  // ── PUBLIC-DOCS — 발행한 문서에 올라온 변경 제안 ────────────────────────
515
691
 
516
692
  async listDocumentCommits(documentId, status = 'OPEN') {
@@ -564,10 +740,10 @@ export class RootrClient {
564
740
  }
565
741
 
566
742
  /** Create a brand-new workspace from a scaffold tree. Requires an account key (PAT) with workspaces:create. */
567
- async createWorkspaceScaffold({ name, intent, tree }) {
743
+ async createWorkspaceScaffold({ name, intent, tree, mode }) {
568
744
  return this.request('/scaffold/workspace', {
569
745
  method: 'POST',
570
- body: { name, intent, tree },
746
+ body: { name, intent, tree, mode },
571
747
  });
572
748
  }
573
749
 
@@ -592,18 +768,18 @@ export class RootrClient {
592
768
  * Check a designed tree BEFORE building it — the compiler pass of scaffolding.
593
769
  * Needs no workspace, creates nothing. Returns { score, counts, findings, summary }.
594
770
  */
595
- async scaffoldReview(tree) {
596
- return this.request('/scaffold/review', { method: 'POST', body: { tree } });
771
+ async scaffoldReview(tree, mode) {
772
+ return this.request('/scaffold/review', { method: 'POST', body: { tree, mode } });
597
773
  }
598
774
 
599
775
  /**
600
776
  * Check the workspace that was ACTUALLY built (not the tree you sent).
601
777
  * Optional rootPath narrows it to one subtree.
602
778
  */
603
- async scaffoldReviewBuilt(workspace, rootPath) {
779
+ async scaffoldReviewBuilt(workspace, rootPath, mode) {
604
780
  return this.request(`/workspaces/${encodeURIComponent(workspace)}/scaffold/review-built`, {
605
781
  method: 'POST',
606
- body: { rootPath },
782
+ body: { rootPath, mode },
607
783
  });
608
784
  }
609
785
 
@@ -620,10 +796,10 @@ export class RootrClient {
620
796
  }
621
797
 
622
798
  /** Apply a scaffold tree to an existing workspace. */
623
- async scaffoldApply(workspace, { rootPath, tree } = {}) {
799
+ async scaffoldApply(workspace, { rootPath, tree, mode } = {}) {
624
800
  return this.request(`/workspaces/${encodeURIComponent(workspace)}/scaffold/apply`, {
625
801
  method: 'POST',
626
- body: { rootPath, tree },
802
+ body: { rootPath, tree, mode },
627
803
  });
628
804
  }
629
805
 
@@ -690,13 +866,21 @@ export class RootrClient {
690
866
  async ask(workspaceId, question) {
691
867
  this._requireApiKey();
692
868
  const graphqlUrl = `${this.baseUrl.replace(/\/v1\/?$/, '')}/graphql`;
693
- const query =
694
- 'query($ws:String!,$q:String!){ask(workspaceId:$ws,question:$q){text citations{documentPath quote}}}';
695
- const data = await this.request(null, {
696
- url: graphqlUrl,
697
- method: 'POST',
698
- body: { query, variables: { ws: workspaceId, q: question } },
699
- });
869
+ // DB-SEARCH — 표의 행에서 온 출처는 행 제목·행 주소를 같이 받는다. 이 필드를 모르는 옛 서버에
870
+ // 붙으면 GraphQL 이 질문 전체를 거절하므로, 그때만 예전 모양으로 한 번 더 묻는다.
871
+ const ask = (fields) =>
872
+ this.request(null, {
873
+ url: graphqlUrl,
874
+ method: 'POST',
875
+ body: {
876
+ query: `query($ws:String!,$q:String!){ask(workspaceId:$ws,question:$q){text citations{${fields}}}}`,
877
+ variables: { ws: workspaceId, q: question },
878
+ },
879
+ });
880
+ let data = await ask('documentTitle documentPath quote rowId rowTitle url');
881
+ if (data && Array.isArray(data.errors) && data.errors.some((e) => /Cannot query field/.test(e.message || ''))) {
882
+ data = await ask('documentPath quote');
883
+ }
700
884
  if (data && Array.isArray(data.errors) && data.errors.length) {
701
885
  const msg = data.errors.map((e) => e.message || String(e)).join('; ');
702
886
  throw new RootrApiError(tr(`ask 오류: ${msg}`, `ask failed: ${msg}`), { body: data.errors });
@@ -901,7 +1085,11 @@ export class RootrClient {
901
1085
  const query = params.length ? `?${params.join('&')}` : '';
902
1086
  const data = await this.request(`/databases/${encodeURIComponent(databaseId)}/rows${query}`);
903
1087
  if (Array.isArray(data)) return data;
904
- if (data && Array.isArray(data.rows)) return data.rows;
1088
+ if (data && Array.isArray(data.rows)) {
1089
+ // 벡터 칸이 있는 표는 서버가 행마다 채워진 벡터 칸(칸 id → 차원)을 `vectors` 로 따로 준다 — 행에 붙여 MCP 에도 보인다.
1090
+ if (!data.vectors) return data.rows;
1091
+ return data.rows.map((r) => ({ ...r, vectors: data.vectors[r.id] ?? {} }));
1092
+ }
905
1093
  return [];
906
1094
  }
907
1095
 
@@ -927,10 +1115,12 @@ export class RootrClient {
927
1115
  * DB-BULK — 여러 행을 **한 번에 넣는다**. 전부 아니면 전무다.
928
1116
  * 칸은 이름으로도 id로도 적을 수 있다.
929
1117
  */
930
- async bulkCreateRows(databaseId, rows) {
1118
+ async bulkCreateRows(databaseId, rows, upsertKey) {
931
1119
  return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/bulk`, {
932
1120
  method: 'POST',
933
- body: { rows },
1121
+ // BULK-UPSERT — 열쇠 칸을 주면 이미 있는 행은 고치고 없는 것만 만든다.
1122
+ // 재시도가 안전해야 외부 시스템 동기화를 만들 수 있다(2026-09-18 요청 5번).
1123
+ body: { rows, ...(upsertKey ? { upsertKey } : {}) },
934
1124
  });
935
1125
  }
936
1126
 
@@ -1018,18 +1208,51 @@ export class RootrClient {
1018
1208
  // PATCH /databases/:id replaces the whole properties array, so rebuilding it
1019
1209
  // client-side drops any column left out. These merge server-side instead.
1020
1210
 
1021
- async addColumn(databaseId, { name, type, options, relationDatabaseId, formula, rollup, prefix } = {}) {
1211
+ async addColumn(
1212
+ databaseId,
1213
+ { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix, searchExcluded, dimensions } = {},
1214
+ ) {
1022
1215
  return this.request(`/databases/${encodeURIComponent(databaseId)}/columns`, {
1023
1216
  method: 'POST',
1024
1217
  // 계산 칸(수식·롤업·번호)은 설정이 곧 그 칸이라 같이 보내야 한다.
1025
- body: { name, type, options, relationDatabaseId, formula, rollup, prefix },
1218
+ // reverseName 은 상대 표에 짝 칸을 만든다(양방향 관계).
1219
+ // searchExcluded 는 "검색 색인에서 제외"(표 관리자만).
1220
+ body: { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix, searchExcluded, dimensions },
1026
1221
  });
1027
1222
  }
1028
1223
 
1029
- async updateColumn(databaseId, propertyId, { name, options } = {}) {
1224
+ async updateColumn(databaseId, propertyId, { name, options, formula, rollup, prefix, reverseName, searchExcluded } = {}) {
1030
1225
  return this.request(
1031
1226
  `/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}`,
1032
- { method: 'PATCH', body: { name, options } },
1227
+ { method: 'PATCH', body: { name, options, formula, rollup, prefix, reverseName, searchExcluded } },
1228
+ );
1229
+ }
1230
+
1231
+ // DB-VECTOR — 에이전트용 벡터 칸(칸 id 또는 이름).
1232
+ async putVectors(databaseId, column, items) {
1233
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/vectors/${encodeURIComponent(column)}`, {
1234
+ method: 'PUT',
1235
+ body: { items },
1236
+ });
1237
+ }
1238
+
1239
+ async queryVectors(databaseId, column, { vector, topK, minScore } = {}) {
1240
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/vectors/${encodeURIComponent(column)}/query`, {
1241
+ method: 'POST',
1242
+ body: { vector, topK, minScore },
1243
+ });
1244
+ }
1245
+
1246
+ async getRowVector(databaseId, rowId, column) {
1247
+ return this.request(
1248
+ `/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/vectors/${encodeURIComponent(column)}`,
1249
+ );
1250
+ }
1251
+
1252
+ async deleteRowVector(databaseId, rowId, column) {
1253
+ return this.request(
1254
+ `/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/vectors/${encodeURIComponent(column)}`,
1255
+ { method: 'DELETE' },
1033
1256
  );
1034
1257
  }
1035
1258
 
@@ -1296,7 +1519,8 @@ export class RootrClient {
1296
1519
  * redirect to the storage bucket isn't sent our API key.
1297
1520
  */
1298
1521
  async downloadAttachmentBytes(attachmentId) {
1299
- const url = this.attachmentRawUrl(attachmentId);
1522
+ // ISSUE-61 — 바이트는 우리가 직접 받는다: 공개 주소(publicBaseUrl)가 아니라 요청을 보내는 baseUrl 로.
1523
+ const url = `${this.baseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
1300
1524
  const res = await fetch(url);
1301
1525
  if (!res.ok) {
1302
1526
  throw new RootrApiError(tr(`오디오 다운로드 실패 (HTTP ${res.status})`, `Audio download failed (HTTP ${res.status})`), { status: res.status });
@@ -1425,7 +1649,7 @@ export class RootrClient {
1425
1649
 
1426
1650
  /** Public, unauthenticated URL an <img> tag (or a published /p/ document) can load directly. */
1427
1651
  attachmentRawUrl(attachmentId) {
1428
- return `${this.baseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
1652
+ return `${this.publicBaseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
1429
1653
  }
1430
1654
 
1431
1655
  // ---- Spreadsheets ---------------------------------------------------------
@@ -155,6 +155,22 @@ export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
155
155
 
156
156
  if (target) {
157
157
  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.
165
+ 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
+ : '';
158
174
  return {
159
175
  error: tr(
160
176
  `"${target.name || target.id}" (${target.id}) 는 접근 권한 목록에는 있지만, 등록된 키로는 열 수 없습니다` +
@@ -164,7 +180,8 @@ export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
164
180
  : `원인: ${lastRejection ? lastRejection.message : '알 수 없음'}\n`) +
165
181
  '조치 (둘 중 하나, 사용자가 직접):\n' +
166
182
  '1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → `rootr config --api-key <키>` (모든 워크스페이스에 통함)\n' +
167
- `2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\``,
183
+ `2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\`` +
184
+ restartNote,
168
185
  `"${target.name || target.id}" (${target.id}) is on your access list, but none of the registered keys opens it` +
169
186
  `${target.role ? ` (your role: ${target.role})` : ''}.\n` +
170
187
  (scoped
@@ -172,7 +189,8 @@ export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
172
189
  : `Cause: ${lastRejection ? lastRejection.message : 'unknown'}\n`) +
173
190
  'Fix (either one, done by you):\n' +
174
191
  '1) Create an account key (PAT) under rootr.io > Settings > API keys → `rootr config --api-key <key>` (works everywhere)\n' +
175
- `2) Create a key for that workspace → \`rootr config --add-key <key> --workspace ${target.id}\``,
192
+ `2) Create a key for that workspace → \`rootr config --add-key <key> --workspace ${target.id}\`` +
193
+ restartNote,
176
194
  ),
177
195
  };
178
196
  }
@@ -28,7 +28,12 @@ export function registerAskTools(server) {
28
28
  const client = makeClient(ws);
29
29
  const result = await client.ask(ws, question);
30
30
  const citations = Array.isArray(result.citations) ? result.citations : [];
31
- const citationLines = citations.map((c) => `- ${c.documentPath}: ${c.quote}`);
31
+ // DB-SEARCH — 표의 행 출처는 "표 › 행 (주소)" — 표 이름만 주면 찾아 놓고도 그 행을 못 연다.
32
+ const citationLines = citations.map((c) =>
33
+ c.rowId
34
+ ? `- ${c.documentTitle || c.documentPath} › ${c.rowTitle || '(untitled row)'}${c.url ? ` (${c.url})` : ''}: ${c.quote}`
35
+ : `- ${c.documentPath}${c.url ? ` (${c.url})` : ''}: ${c.quote}`,
36
+ );
32
37
  const text = [
33
38
  result.text || '(no answer text)',
34
39
  '',
@@ -2,33 +2,50 @@ import { readFileSync } from 'node:fs';
2
2
 
3
3
  import { z } from 'zod';
4
4
 
5
- import { jsonResult, errorResult, makeClient, requireWorkspace, resolveOptionalNodeId, NODE_TARGET_HINT } from './shared.js';
5
+ import {
6
+ jsonResult,
7
+ errorResult,
8
+ makeClient,
9
+ requireWorkspace,
10
+ resolveOptionalNodeId,
11
+ NODE_TARGET_HINT,
12
+ assertLocalFilesAllowed,
13
+ isHostedMcp,
14
+ localFileArg,
15
+ } from './shared.js';
6
16
  import { resolveTarget, requireResolved } from '../resolve.js';
7
17
  import { RootrApiError } from '../client.js';
8
18
 
9
19
  /** Reads file bytes from a local path or a base64 string; exactly one must be given. */
10
20
  function readAttachmentBytes({ filePath, base64Data }) {
21
+ // SEC-MCP-FILES — 원격 커넥터에서는 서버 파일을 읽게 되므로 읽기 전에 거절한다.
22
+ assertLocalFilesAllowed(filePath);
11
23
  if (filePath) return readFileSync(filePath);
12
24
  if (base64Data) return Buffer.from(base64Data, 'base64');
25
+ if (isHostedMcp()) throw new RootrApiError('base64Data 가 필요합니다 (원격 커넥터에서는 filePath 를 쓸 수 없습니다).');
13
26
  throw new RootrApiError('filePath 또는 base64Data 중 하나가 필요합니다.');
14
27
  }
15
28
 
16
- const uploadInputSchema = {
29
+ // 등록 시점(원격이면 요청마다)에 만든다 — 원격 커넥터에서는 filePath 칸이 스키마에서 빠진다.
30
+ const buildUploadInputSchema = () => ({
17
31
  filename: z.string().describe('File name to store, e.g. "screenshot.png"'),
18
32
  mimeType: z.string().describe('MIME type, e.g. "image/png"'),
19
- filePath: z
20
- .string()
21
- .optional()
22
- .describe('Local filesystem path to the file to upload (the MCP server reads this itself, e.g. "/tmp/screenshot.png")'),
33
+ ...localFileArg(
34
+ z
35
+ .string()
36
+ .optional()
37
+ .describe('Local filesystem path to the file to upload (the MCP server reads this itself, e.g. "/tmp/screenshot.png")'),
38
+ ),
23
39
  base64Data: z
24
40
  .string()
25
41
  .optional()
26
42
  .describe('Base64-encoded file bytes; use this when the caller has the file in memory instead of on disk'),
27
43
  nodeId: z.string().optional().describe(`Document/node id${NODE_TARGET_HINT} to associate the attachment with (permission-checked)`),
28
44
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
29
- };
45
+ });
30
46
 
31
47
  export function registerAttachmentTools(server) {
48
+ const uploadInputSchema = buildUploadInputSchema();
32
49
  server.registerTool(
33
50
  'rootr_upload_attachment',
34
51
  {
@@ -46,8 +63,11 @@ export function registerAttachmentTools(server) {
46
63
  },
47
64
  async ({ filename, mimeType, filePath, base64Data, nodeId, workspace }) => {
48
65
  try {
49
- const client = makeClient();
66
+ // ISSUE-61 — workspace 를 먼저 정하고 그 워크스페이스의 키로 클라이언트를 만든다. 인자 없이
67
+ // makeClient() 를 부르면 기본 키가 골라져, 키를 워크스페이스별로 둔 구성에서 403 이 났다.
68
+ // (원격 커넥터는 makeClient 가 연결된 워크스페이스와 다른 값을 거절한다.)
50
69
  const ws = requireWorkspace(workspace);
70
+ const client = makeClient(ws);
51
71
  nodeId = await resolveOptionalNodeId(client, nodeId);
52
72
  const data = readAttachmentBytes({ filePath, base64Data });
53
73
  const { id } = await client.uploadAttachment(ws, { filename, mimeType, nodeId, data });
@@ -81,7 +101,7 @@ export function registerAttachmentTools(server) {
81
101
  target: z.string().describe('Document path (starting with "/") or node id to insert the image into'),
82
102
  filename: uploadInputSchema.filename,
83
103
  mimeType: uploadInputSchema.mimeType,
84
- filePath: uploadInputSchema.filePath,
104
+ ...(uploadInputSchema.filePath ? { filePath: uploadInputSchema.filePath } : {}),
85
105
  base64Data: uploadInputSchema.base64Data,
86
106
  alt: z.string().optional().describe('Alt text / caption for the image (defaults to filename)'),
87
107
  underHeading: z
@@ -93,8 +113,8 @@ export function registerAttachmentTools(server) {
93
113
  },
94
114
  async ({ target, filename, mimeType, filePath, base64Data, alt, underHeading, workspace }) => {
95
115
  try {
96
- const client = makeClient();
97
- const ws = requireWorkspace(workspace);
116
+ const ws = requireWorkspace(workspace); // ISSUE-61 — 위와 같다: 워크스페이스 → 그 키
117
+ const client = makeClient(ws);
98
118
  const resolved = await resolveTarget(client, target);
99
119
  const nodeId = requireResolved(resolved);
100
120
  const data = readAttachmentBytes({ filePath, base64Data });