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/README.ko.md +107 -3
- package/README.md +86 -4
- package/bin/rootr.js +688 -13
- package/lib/client.js +250 -26
- package/lib/credentials.js +20 -2
- package/lib/mcp-tools/ask.js +6 -1
- package/lib/mcp-tools/attachments.js +31 -11
- package/lib/mcp-tools/booking.js +403 -0
- package/lib/mcp-tools/databases.js +490 -20
- package/lib/mcp-tools/documents.js +183 -16
- package/lib/mcp-tools/forms.js +63 -1
- package/lib/mcp-tools/logs.js +1 -1
- package/lib/mcp-tools/misc.js +9 -3
- package/lib/mcp-tools/pages.js +31 -7
- package/lib/mcp-tools/presentations.js +36 -12
- package/lib/mcp-tools/public.js +4 -1
- package/lib/mcp-tools/recordings.js +39 -15
- package/lib/mcp-tools/shared.js +28 -0
- package/lib/mcp-tools/workspaces.js +155 -77
- package/lib/mcp.js +3 -0
- package/lib/resolve.js +43 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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))
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1652
|
+
return `${this.publicBaseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
|
|
1429
1653
|
}
|
|
1430
1654
|
|
|
1431
1655
|
// ---- Spreadsheets ---------------------------------------------------------
|
package/lib/credentials.js
CHANGED
|
@@ -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
|
}
|
package/lib/mcp-tools/ask.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
const
|
|
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 });
|