rootr-cli 0.18.0 → 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 +49 -2
- package/README.md +46 -4
- package/bin/rootr.js +481 -11
- package/lib/client.js +183 -24
- 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 +230 -9
- package/lib/mcp-tools/documents.js +183 -16
- package/lib/mcp-tools/forms.js +3 -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
|
// 노션에 없는 것들이라, 에이전트가 표를 만들어 놓고도 "지원서는 서로 못 보게" 같은
|
|
@@ -623,10 +740,10 @@ export class RootrClient {
|
|
|
623
740
|
}
|
|
624
741
|
|
|
625
742
|
/** Create a brand-new workspace from a scaffold tree. Requires an account key (PAT) with workspaces:create. */
|
|
626
|
-
async createWorkspaceScaffold({ name, intent, tree }) {
|
|
743
|
+
async createWorkspaceScaffold({ name, intent, tree, mode }) {
|
|
627
744
|
return this.request('/scaffold/workspace', {
|
|
628
745
|
method: 'POST',
|
|
629
|
-
body: { name, intent, tree },
|
|
746
|
+
body: { name, intent, tree, mode },
|
|
630
747
|
});
|
|
631
748
|
}
|
|
632
749
|
|
|
@@ -651,18 +768,18 @@ export class RootrClient {
|
|
|
651
768
|
* Check a designed tree BEFORE building it — the compiler pass of scaffolding.
|
|
652
769
|
* Needs no workspace, creates nothing. Returns { score, counts, findings, summary }.
|
|
653
770
|
*/
|
|
654
|
-
async scaffoldReview(tree) {
|
|
655
|
-
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 } });
|
|
656
773
|
}
|
|
657
774
|
|
|
658
775
|
/**
|
|
659
776
|
* Check the workspace that was ACTUALLY built (not the tree you sent).
|
|
660
777
|
* Optional rootPath narrows it to one subtree.
|
|
661
778
|
*/
|
|
662
|
-
async scaffoldReviewBuilt(workspace, rootPath) {
|
|
779
|
+
async scaffoldReviewBuilt(workspace, rootPath, mode) {
|
|
663
780
|
return this.request(`/workspaces/${encodeURIComponent(workspace)}/scaffold/review-built`, {
|
|
664
781
|
method: 'POST',
|
|
665
|
-
body: { rootPath },
|
|
782
|
+
body: { rootPath, mode },
|
|
666
783
|
});
|
|
667
784
|
}
|
|
668
785
|
|
|
@@ -679,10 +796,10 @@ export class RootrClient {
|
|
|
679
796
|
}
|
|
680
797
|
|
|
681
798
|
/** Apply a scaffold tree to an existing workspace. */
|
|
682
|
-
async scaffoldApply(workspace, { rootPath, tree } = {}) {
|
|
799
|
+
async scaffoldApply(workspace, { rootPath, tree, mode } = {}) {
|
|
683
800
|
return this.request(`/workspaces/${encodeURIComponent(workspace)}/scaffold/apply`, {
|
|
684
801
|
method: 'POST',
|
|
685
|
-
body: { rootPath, tree },
|
|
802
|
+
body: { rootPath, tree, mode },
|
|
686
803
|
});
|
|
687
804
|
}
|
|
688
805
|
|
|
@@ -749,13 +866,21 @@ export class RootrClient {
|
|
|
749
866
|
async ask(workspaceId, question) {
|
|
750
867
|
this._requireApiKey();
|
|
751
868
|
const graphqlUrl = `${this.baseUrl.replace(/\/v1\/?$/, '')}/graphql`;
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
+
}
|
|
759
884
|
if (data && Array.isArray(data.errors) && data.errors.length) {
|
|
760
885
|
const msg = data.errors.map((e) => e.message || String(e)).join('; ');
|
|
761
886
|
throw new RootrApiError(tr(`ask 오류: ${msg}`, `ask failed: ${msg}`), { body: data.errors });
|
|
@@ -960,7 +1085,11 @@ export class RootrClient {
|
|
|
960
1085
|
const query = params.length ? `?${params.join('&')}` : '';
|
|
961
1086
|
const data = await this.request(`/databases/${encodeURIComponent(databaseId)}/rows${query}`);
|
|
962
1087
|
if (Array.isArray(data)) return data;
|
|
963
|
-
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
|
+
}
|
|
964
1093
|
return [];
|
|
965
1094
|
}
|
|
966
1095
|
|
|
@@ -1081,20 +1210,49 @@ export class RootrClient {
|
|
|
1081
1210
|
|
|
1082
1211
|
async addColumn(
|
|
1083
1212
|
databaseId,
|
|
1084
|
-
{ name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix } = {},
|
|
1213
|
+
{ name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix, searchExcluded, dimensions } = {},
|
|
1085
1214
|
) {
|
|
1086
1215
|
return this.request(`/databases/${encodeURIComponent(databaseId)}/columns`, {
|
|
1087
1216
|
method: 'POST',
|
|
1088
1217
|
// 계산 칸(수식·롤업·번호)은 설정이 곧 그 칸이라 같이 보내야 한다.
|
|
1089
1218
|
// reverseName 은 상대 표에 짝 칸을 만든다(양방향 관계).
|
|
1090
|
-
|
|
1219
|
+
// searchExcluded 는 "검색 색인에서 제외"(표 관리자만).
|
|
1220
|
+
body: { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix, searchExcluded, dimensions },
|
|
1091
1221
|
});
|
|
1092
1222
|
}
|
|
1093
1223
|
|
|
1094
|
-
async updateColumn(databaseId, propertyId, { name, options, formula, rollup, prefix, reverseName } = {}) {
|
|
1224
|
+
async updateColumn(databaseId, propertyId, { name, options, formula, rollup, prefix, reverseName, searchExcluded } = {}) {
|
|
1095
1225
|
return this.request(
|
|
1096
1226
|
`/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}`,
|
|
1097
|
-
{ method: 'PATCH', body: { name, options, formula, rollup, prefix, reverseName } },
|
|
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' },
|
|
1098
1256
|
);
|
|
1099
1257
|
}
|
|
1100
1258
|
|
|
@@ -1361,7 +1519,8 @@ export class RootrClient {
|
|
|
1361
1519
|
* redirect to the storage bucket isn't sent our API key.
|
|
1362
1520
|
*/
|
|
1363
1521
|
async downloadAttachmentBytes(attachmentId) {
|
|
1364
|
-
|
|
1522
|
+
// ISSUE-61 — 바이트는 우리가 직접 받는다: 공개 주소(publicBaseUrl)가 아니라 요청을 보내는 baseUrl 로.
|
|
1523
|
+
const url = `${this.baseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
|
|
1365
1524
|
const res = await fetch(url);
|
|
1366
1525
|
if (!res.ok) {
|
|
1367
1526
|
throw new RootrApiError(tr(`오디오 다운로드 실패 (HTTP ${res.status})`, `Audio download failed (HTTP ${res.status})`), { status: res.status });
|
|
@@ -1490,7 +1649,7 @@ export class RootrClient {
|
|
|
1490
1649
|
|
|
1491
1650
|
/** Public, unauthenticated URL an <img> tag (or a published /p/ document) can load directly. */
|
|
1492
1651
|
attachmentRawUrl(attachmentId) {
|
|
1493
|
-
return `${this.
|
|
1652
|
+
return `${this.publicBaseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
|
|
1494
1653
|
}
|
|
1495
1654
|
|
|
1496
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 });
|