rootr-cli 0.33.0 → 0.34.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 +7 -4
- package/README.md +9 -2
- package/bin/rootr.js +86 -13
- package/lib/client.js +72 -2
- package/lib/mcp-tools/databases.js +14 -3
- package/lib/mcp-tools/documents.js +4 -1
- package/lib/mcp-tools/issues.js +99 -10
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -405,7 +405,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
405
405
|
|
|
406
406
|
## MCP 서버로 사용하기
|
|
407
407
|
|
|
408
|
-
`rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구
|
|
408
|
+
`rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 221종:
|
|
409
409
|
|
|
410
410
|
노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
|
|
411
411
|
`recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
|
|
@@ -488,11 +488,14 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
488
488
|
| `rootr_update_issue` | 이슈 갱신(닫기: state=CLOSED + stateReason) |
|
|
489
489
|
| `rootr_comment_issue` | 이슈에 댓글 추가 |
|
|
490
490
|
| `rootr_update_issue_tracker` | 트래커 설정 — **외부 참여**(`read` 보기만 · `login` 로그인한 사람만 작성 · `anyone` 누구나 작성), 라벨·유형 |
|
|
491
|
-
| `rootr_submit_public_issue` | **웹게시한** 트래커에 밖에서 이슈 올리기(공개 주소로, `asGuest`+`guestName` 이면 손님으로, 페이지 블록은 `blockId`) |
|
|
492
|
-
| `rootr_comment_public_issue` | 웹게시한 트래커의 이슈에 댓글(`read:true` 면 공개 댓글 읽기) |
|
|
491
|
+
| `rootr_submit_public_issue` | **웹게시한** 트래커에 밖에서 이슈 올리기(공개 주소로, `asGuest`+`guestName` 이면 손님으로, 페이지 블록은 `blockId`, `files:[{filename, contentBase64}]` 첨부) |
|
|
492
|
+
| `rootr_comment_public_issue` | 웹게시한 트래커의 이슈에 댓글(`files` 첨부, `read:true` 면 공개 댓글 읽기) |
|
|
493
|
+
| `rootr_search_public_issues` | 웹게시한 트래커를 밖에서 찾기 — `q`(제목·본문·#번호·쓴 사람), `labels`(모두 가진 것), `state`, `sort`(newest·oldest·comments) |
|
|
493
494
|
|
|
494
495
|
CLI: `rootr issue tracker <트래커> --participation anyone` · `rootr issue list|create|comment …` ·
|
|
495
|
-
`rootr issue
|
|
496
|
+
`rootr issue list <공개 주소> --q "결제" --label 버그` · `rootr issue submit <공개 주소> --title "…" --name <이름> --file 화면.png --file 로그.txt` ·
|
|
497
|
+
`rootr issue reply <공개 주소> <#번호> "<댓글>" [--file <경로>]`.
|
|
498
|
+
첨부는 그림(png·jpg·gif·webp)·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일 10MB·글 하나에 10개까지입니다(확장자와 파일 첫 바이트가 맞아야 함).
|
|
496
499
|
외부 참여는 **트래커 설정**이라 페이지(PAGE)에 `issues` 블록으로 끼운 트래커도 같은 규칙을 따릅니다.
|
|
497
500
|
|
|
498
501
|
#### 웹훅 (webhooks:manage 스코프)
|
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, **
|
|
14
|
+
- `rootr mcp` — stdio MCP server, **221 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
|
|
|
@@ -311,9 +311,16 @@ rootr issue list /support/feedback --state ALL
|
|
|
311
311
|
rootr issue create /support/feedback --title "Export is slow" --body "…"
|
|
312
312
|
rootr issue comment /support/feedback 12 "Fixed in 1.4"
|
|
313
313
|
rootr issue submit https://rootr.io/ko/p/AbCd123 --title "Button does nothing" --name "Kim" # from outside, as a guest
|
|
314
|
+
rootr issue submit https://rootr.io/ko/p/AbCd123 --title "Crash" --file shot.png --file app.log --name "Kim" # with files
|
|
314
315
|
rootr issue reply https://rootr.io/ko/p/AbCd123 12 "Same here" --name "Lee"
|
|
316
|
+
rootr issue list https://rootr.io/ko/p/AbCd123 --q "payment" --label bug --sort comments # search a published tracker
|
|
315
317
|
```
|
|
316
318
|
|
|
319
|
+
Outside attachments: images (png·jpg·gif·webp), pdf, txt·csv·log, docx·xlsx·pptx, zip — 10MB per file, 10 per
|
|
320
|
+
issue/comment; the extension and the file's first bytes must agree. MCP: `rootr_submit_public_issue` /
|
|
321
|
+
`rootr_comment_public_issue` take `files: [{ filename, contentBase64 }]` (contents, never a path), and
|
|
322
|
+
`rootr_search_public_issues` searches a published tracker the way a visitor does.
|
|
323
|
+
|
|
317
324
|
Every node type publishes on the Free plan **except PAGE** (the block-built web page): publishing a
|
|
318
325
|
page to the web needs the **Team plan**. On Free, `rootr publish <page>` fails with
|
|
319
326
|
`PLAN_UPGRADE_REQUIRED` and nothing changes; `--children` skips pages underneath and says how many.
|
|
@@ -533,7 +540,7 @@ is refused and says when the grace was used.
|
|
|
533
540
|
|
|
534
541
|
## MCP tools
|
|
535
542
|
|
|
536
|
-
`rootr mcp` speaks MCP over stdio.
|
|
543
|
+
`rootr mcp` speaks MCP over stdio. 221 tools:
|
|
537
544
|
|
|
538
545
|
| Group | Count | Tools |
|
|
539
546
|
|---|---|---|
|
package/bin/rootr.js
CHANGED
|
@@ -5,7 +5,16 @@ 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 {
|
|
8
|
+
import {
|
|
9
|
+
RootrClient,
|
|
10
|
+
RootrApiError,
|
|
11
|
+
imageMarkdown,
|
|
12
|
+
publicIssueMimeOf,
|
|
13
|
+
withPublicAttachments,
|
|
14
|
+
PUBLIC_ISSUE_FILE_EXTENSIONS,
|
|
15
|
+
PUBLIC_ISSUE_FILE_MAX_BYTES,
|
|
16
|
+
PUBLIC_ISSUE_FILES_PER_POST,
|
|
17
|
+
} from '../lib/client.js';
|
|
9
18
|
import { setGlobalDocMailSink } from '../lib/context.js';
|
|
10
19
|
import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
|
|
11
20
|
import { t as tr, lang } from '../lib/i18n.js';
|
|
@@ -78,12 +87,14 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
78
87
|
rootr publish <path|id> --password <비번> | --no-password 공개 주소 잠그기(Team 부터)·풀기 — 이슈트래커는 쓰기도 같이 잠김
|
|
79
88
|
rootr publish <이슈트래커> --participation read|login|anyone 외부 참여 — 보기만·로그인한 사람만 작성·누구나 작성(이름만 적고)
|
|
80
89
|
rootr issue tracker <트래커> [--participation read|login|anyone] [--json] 트래커 설정 보기·외부 참여 바꾸기(MANAGE)
|
|
81
|
-
rootr issue list
|
|
90
|
+
rootr issue list <트래커|공개 주소> [--state OPEN|CLOSED|ALL] [--q <검색어>] [--label <라벨>]… [--sort newest|oldest|comments] [--json]
|
|
91
|
+
이슈 목록·검색(공개 주소면 밖에서 — 제목·본문·#번호·쓴 사람, 페이지 블록은 --block)
|
|
82
92
|
rootr issue create <트래커> --title "<제목>" [--body "<본문>"] 이슈 올리기(내 계정으로)
|
|
83
93
|
rootr issue comment <트래커> <#번호> "<댓글>" 이슈에 댓글
|
|
84
|
-
rootr issue submit <공개 주소> --title "<제목>" [--body …] [--name <이름>] [--block <블록 id>] [--password <비번>]
|
|
94
|
+
rootr issue submit <공개 주소> --title "<제목>" [--body …] [--file <경로>]… [--name <이름>] [--block <블록 id>] [--password <비번>]
|
|
85
95
|
웹게시한 트래커에 밖에서 이슈 올리기(--name 이면 키 없이 손님으로, 페이지 블록은 --block)
|
|
86
|
-
|
|
96
|
+
--file: 그림·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일 10MB·글 하나에 10개
|
|
97
|
+
rootr issue reply <공개 주소> <#번호> "<댓글>" [--file <경로>]… [--name <이름>] [--block <블록 id>] [--password <비번>] 웹게시한 트래커 이슈에 댓글
|
|
87
98
|
rootr booking show <표> 방문자 일정 등록(예약) 설정 보기
|
|
88
99
|
rootr booking set <표> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 예약 켜기·규칙
|
|
89
100
|
rootr booking set <표> --ask "연락처:required" --approval manual 방문자에게 더 묻기 · 승인 후 확정
|
|
@@ -232,12 +243,14 @@ usage:
|
|
|
232
243
|
rootr publish <path|id> --password <pw> | --no-password lock (Team plan) / unlock the public address — on a tracker writing is locked too
|
|
233
244
|
rootr publish <issue tracker> --participation read|login|anyone outside participation — read-only · signed-in users write · anyone writes (just a name)
|
|
234
245
|
rootr issue tracker <tracker> [--participation read|login|anyone] [--json] show tracker settings / change outside participation (MANAGE)
|
|
235
|
-
rootr issue list <tracker> [--state OPEN|CLOSED|ALL] [--q <text>] [--json]
|
|
246
|
+
rootr issue list <tracker|public URL> [--state OPEN|CLOSED|ALL] [--q <text>] [--label <name>]… [--sort newest|oldest|comments] [--json]
|
|
247
|
+
list/search issues (a public URL searches a PUBLISHED tracker from outside — title, body, #number, author; --block for a page)
|
|
236
248
|
rootr issue create <tracker> --title "<title>" [--body "<body>"] file an issue (as your account)
|
|
237
249
|
rootr issue comment <tracker> <#number> "<comment>" comment on an issue
|
|
238
|
-
rootr issue submit <public URL> --title "<title>" [--body …] [--name <name>] [--block <block id>] [--password <pw>]
|
|
250
|
+
rootr issue submit <public URL> --title "<title>" [--body …] [--file <path>]… [--name <name>] [--block <block id>] [--password <pw>]
|
|
239
251
|
file an issue on a PUBLISHED tracker from outside (--name = as a guest, no key; --block for a tracker on a page)
|
|
240
|
-
|
|
252
|
+
--file: images·pdf·txt·csv·log·docx·xlsx·pptx·zip, 10MB per file, 10 per issue/comment
|
|
253
|
+
rootr issue reply <public URL> <#number> "<comment>" [--file <path>]… [--name <name>] [--block <block id>] [--password <pw>] comment on an issue of a published tracker
|
|
241
254
|
rootr booking show <table> show visitor booking settings
|
|
242
255
|
rootr booking set <table> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 turn on + rules
|
|
243
256
|
rootr booking set <table> --ask "Phone:required" --approval manual ask more · confirm after approval
|
|
@@ -324,7 +337,8 @@ function usage(syntax) {
|
|
|
324
337
|
}
|
|
325
338
|
|
|
326
339
|
function parseArgs(argv) {
|
|
327
|
-
|
|
340
|
+
// `__all[key]` — 같은 깃발을 여러 번 준 값 전부(`--file a --file b`). 나머지 명령은 예전처럼 마지막 값(args[key])을 쓴다.
|
|
341
|
+
const args = { _: [], __all: {} };
|
|
328
342
|
for (let i = 0; i < argv.length; i++) {
|
|
329
343
|
const tok = argv[i];
|
|
330
344
|
if (tok.startsWith('--')) {
|
|
@@ -334,6 +348,7 @@ function parseArgs(argv) {
|
|
|
334
348
|
args[key] = true;
|
|
335
349
|
} else {
|
|
336
350
|
args[key] = next;
|
|
351
|
+
(args.__all[key] = args.__all[key] || []).push(next);
|
|
337
352
|
i++;
|
|
338
353
|
}
|
|
339
354
|
} else {
|
|
@@ -2207,6 +2222,28 @@ function issueNumberOf(raw) {
|
|
|
2207
2222
|
return n;
|
|
2208
2223
|
}
|
|
2209
2224
|
|
|
2225
|
+
/** `--file <경로>`(여러 번) → 올릴 파일들. 개수·크기·형식을 보내기 전에 거른다 — 서버 안전선(issue-public-attachments.ts)과 같은 값. */
|
|
2226
|
+
function publicIssueFilesOf(args) {
|
|
2227
|
+
const paths = args.__all.file || (typeof args.file === 'string' ? [args.file] : []);
|
|
2228
|
+
if (paths.length > PUBLIC_ISSUE_FILES_PER_POST) {
|
|
2229
|
+
throw new Error(tr(`파일은 글 하나에 ${PUBLIC_ISSUE_FILES_PER_POST}개까지 붙일 수 있습니다.`, `At most ${PUBLIC_ISSUE_FILES_PER_POST} files per issue/comment.`));
|
|
2230
|
+
}
|
|
2231
|
+
return paths.map((p) => {
|
|
2232
|
+
const filename = basename(p);
|
|
2233
|
+
const mimeType = publicIssueMimeOf(filename);
|
|
2234
|
+
if (!mimeType) {
|
|
2235
|
+
throw new Error(
|
|
2236
|
+
tr(`이 형식은 올릴 수 없습니다: ${filename} — ${PUBLIC_ISSUE_FILE_EXTENSIONS.join('·')} 만`, `File type not allowed: ${filename} — only ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}`),
|
|
2237
|
+
);
|
|
2238
|
+
}
|
|
2239
|
+
const data = readFileSync(p);
|
|
2240
|
+
if (data.length > PUBLIC_ISSUE_FILE_MAX_BYTES) {
|
|
2241
|
+
throw new Error(tr(`파일이 너무 큽니다: ${filename} — 한 파일에 10MB까지`, `File too large: ${filename} — 10MB per file`));
|
|
2242
|
+
}
|
|
2243
|
+
return { filename, mimeType, data };
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2210
2247
|
/**
|
|
2211
2248
|
* ISSUE-PUBLIC — 이슈트래커 명령(창업자 지시 2026-11-02 "api cli mcp scaffold 에도 적용"). 예전엔 CLI 에 이슈 명령이 아예 없었다.
|
|
2212
2249
|
* · 안에서: tracker(설정·외부 참여) · list · create · comment
|
|
@@ -2246,7 +2283,30 @@ async function cmdIssue(args) {
|
|
|
2246
2283
|
}
|
|
2247
2284
|
if (sub === 'list') {
|
|
2248
2285
|
const target = args._[1];
|
|
2249
|
-
if (!target) throw new Error(usage('rootr issue list <path|id
|
|
2286
|
+
if (!target) throw new Error(usage('rootr issue list <path|id|공개 주소> [--state OPEN|CLOSED|ALL] [--q <text>] [--label <이름>]… [--sort newest|oldest|comments] [--json]'));
|
|
2287
|
+
// ISSUE-PUBLIC-SEARCH — 공개 주소(…/p/{slug})나 --block 이면 웹게시한 트래커를 **밖에서** 찾는다(키 없이, 서버 검색).
|
|
2288
|
+
if (/\/p\//.test(String(target)) || typeof args.block === 'string') {
|
|
2289
|
+
const slug = publicSlugOf(target);
|
|
2290
|
+
const pass = typeof args.password === 'string' ? await client.unlockPublicNode(slug, args.password) : undefined;
|
|
2291
|
+
const res = await client.searchPublicIssues(slug, {
|
|
2292
|
+
blockId: typeof args.block === 'string' ? args.block : undefined,
|
|
2293
|
+
q: typeof args.q === 'string' ? args.q : undefined,
|
|
2294
|
+
state: typeof args.state === 'string' ? args.state : undefined,
|
|
2295
|
+
labels: args.__all.label || [],
|
|
2296
|
+
sort: typeof args.sort === 'string' ? args.sort : undefined,
|
|
2297
|
+
pass,
|
|
2298
|
+
});
|
|
2299
|
+
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
2300
|
+
const list = res?.issues || [];
|
|
2301
|
+
if (!list.length) return console.log(tr('조건에 맞는 이슈가 없습니다.', 'No matching issues.'));
|
|
2302
|
+
for (const i of list) {
|
|
2303
|
+
const who = i.author ? (i.author.guest ? tr(`${i.author.name} (외부)`, `${i.author.name} (guest)`) : i.author.name) : '';
|
|
2304
|
+
const labels = (i.labels || []).map((l) => l.name).join(',');
|
|
2305
|
+
console.log(`#${i.number}\t${i.state}\t${i.title}${labels ? `\t[${labels}]` : ''}${who ? `\t${who}` : ''}${i.commentCount ? `\t💬${i.commentCount}` : ''}`);
|
|
2306
|
+
}
|
|
2307
|
+
if (res.truncated) console.log(tr(`… ${res.truncated.total}건 중 ${res.truncated.shown}건`, `… ${res.truncated.shown} of ${res.truncated.total}`));
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2250
2310
|
const id = await resolveNodeId(client, target);
|
|
2251
2311
|
const list = issuesOf(await client.listIssues(id, { state: args.state, q: typeof args.q === 'string' ? args.q : undefined }));
|
|
2252
2312
|
if (args.json) return console.log(JSON.stringify(list, null, 2));
|
|
@@ -2287,16 +2347,29 @@ async function cmdIssue(args) {
|
|
|
2287
2347
|
asGuest,
|
|
2288
2348
|
pass: typeof args.password === 'string' ? await client.unlockPublicNode(slug, args.password) : undefined,
|
|
2289
2349
|
};
|
|
2350
|
+
// ISSUE-PUBLIC-ATTACH — `--file <경로>` 여러 번. 보내기 전에 크기·형식을 걸러(서버가 다시 판정) 올리고, 받은 줄을 본문 끝에 붙인다.
|
|
2351
|
+
const files = publicIssueFilesOf(args);
|
|
2352
|
+
const attach = async () => {
|
|
2353
|
+
const lines = [];
|
|
2354
|
+
for (const f of files) {
|
|
2355
|
+
const out = await client.uploadPublicIssueFile(slug, { ...opts, filename: f.filename, mimeType: f.mimeType, data: f.data });
|
|
2356
|
+
console.log(tr(`첨부: ${out.filename} (${out.size}B)`, `attached: ${out.filename} (${out.size}B)`));
|
|
2357
|
+
lines.push(out.markdown);
|
|
2358
|
+
}
|
|
2359
|
+
return lines;
|
|
2360
|
+
};
|
|
2290
2361
|
if (sub === 'submit') {
|
|
2291
|
-
if (typeof args.title !== 'string') throw new Error(usage('rootr issue submit <public URL> --title "<title>" [--body …] [--name <name>] [--block <id>]'));
|
|
2292
|
-
const
|
|
2362
|
+
if (typeof args.title !== 'string') throw new Error(usage('rootr issue submit <public URL> --title "<title>" [--body …] [--file <경로>]… [--name <name>] [--block <id>]'));
|
|
2363
|
+
const body = withPublicAttachments(typeof args.body === 'string' ? args.body : '', await attach());
|
|
2364
|
+
const out = await client.submitPublicIssue(slug, { ...opts, title: args.title, body: body || undefined });
|
|
2293
2365
|
console.log(out?.number ? tr(`올렸습니다: #${out.number}`, `Filed: #${out.number}`) : tr('올렸습니다.', 'Filed.'));
|
|
2294
2366
|
return;
|
|
2295
2367
|
}
|
|
2296
2368
|
const [, , num, ...rest] = args._;
|
|
2297
2369
|
const text = rest.join(' ').trim();
|
|
2298
|
-
if (!num || !text) throw new Error(usage('rootr issue reply <public URL> <#number> "<comment>" [--name <name>] [--block <id>]'));
|
|
2299
|
-
|
|
2370
|
+
if (!num || (!text && !files.length)) throw new Error(usage('rootr issue reply <public URL> <#number> "<comment>" [--file <경로>]… [--name <name>] [--block <id>]'));
|
|
2371
|
+
const n = issueNumberOf(num);
|
|
2372
|
+
await client.commentPublicIssue(slug, n, { ...opts, body: withPublicAttachments(text, await attach()) });
|
|
2300
2373
|
console.log(tr('댓글을 달았습니다.', 'Comment posted.'));
|
|
2301
2374
|
return;
|
|
2302
2375
|
}
|
package/lib/client.js
CHANGED
|
@@ -115,6 +115,43 @@ function hintFor(status, body) {
|
|
|
115
115
|
return undefined;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* ISSUE-PUBLIC-ATTACH — 공개 이슈트래커 첨부의 안전선(서버 issue-public-attachments.ts 와 같은 값). 서버가 다시 판정하지만, 보내기 전에
|
|
120
|
+
* 걸러 큰 파일을 헛되이 싣지 않게 한다.
|
|
121
|
+
*/
|
|
122
|
+
export const PUBLIC_ISSUE_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
|
123
|
+
export const PUBLIC_ISSUE_FILES_PER_POST = 10;
|
|
124
|
+
const PUBLIC_ISSUE_MIME = {
|
|
125
|
+
png: 'image/png',
|
|
126
|
+
jpg: 'image/jpeg',
|
|
127
|
+
jpeg: 'image/jpeg',
|
|
128
|
+
gif: 'image/gif',
|
|
129
|
+
webp: 'image/webp',
|
|
130
|
+
pdf: 'application/pdf',
|
|
131
|
+
txt: 'text/plain',
|
|
132
|
+
log: 'text/plain',
|
|
133
|
+
csv: 'text/csv',
|
|
134
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
135
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
136
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
137
|
+
zip: 'application/zip',
|
|
138
|
+
};
|
|
139
|
+
export const PUBLIC_ISSUE_FILE_EXTENSIONS = Object.keys(PUBLIC_ISSUE_MIME);
|
|
140
|
+
|
|
141
|
+
/** 파일 이름 → 보낼 mimeType(모르는 확장자면 null — 서버가 415 로 거절할 것을 미리 알린다). */
|
|
142
|
+
export function publicIssueMimeOf(filename) {
|
|
143
|
+
const ext = String(filename || '').split('.').pop().toLowerCase();
|
|
144
|
+
return PUBLIC_ISSUE_MIME[ext] || null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 본문 끝에 첨부 줄(서버가 준 `markdown`)을 붙인다 — 화면(PublicIssueBoard)과 같은 모양. */
|
|
148
|
+
export function withPublicAttachments(body, markdowns) {
|
|
149
|
+
const lines = (markdowns || []).filter(Boolean);
|
|
150
|
+
if (!lines.length) return body || '';
|
|
151
|
+
const text = String(body || '').replace(/\s+$/, '');
|
|
152
|
+
return `${text}${text ? '\n\n' : ''}${lines.join('\n\n')}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
118
155
|
export class RootrClient {
|
|
119
156
|
constructor({ apiKey, workspace, baseUrl, publicBaseUrl, defaultHeaders }) {
|
|
120
157
|
this.apiKey = apiKey;
|
|
@@ -996,6 +1033,39 @@ export class RootrClient {
|
|
|
996
1033
|
});
|
|
997
1034
|
}
|
|
998
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* ISSUE-PUBLIC-SEARCH — 공개 트래커(또는 페이지 블록)의 목록을 서버에서 찾는다. `q` 는 제목·본문·번호(#12)·(이름을 싣는 모드면) 쓴 사람,
|
|
1038
|
+
* `labels` 는 이름(모두 가진 것), `state` OPEN|CLOSED, `sort` newest|oldest|comments. 키 없이 부른다(공개 읽기).
|
|
1039
|
+
*/
|
|
1040
|
+
async searchPublicIssues(slug, { blockId, q, state, labels, sort, pass } = {}) {
|
|
1041
|
+
const qs = new URLSearchParams();
|
|
1042
|
+
if (q) qs.set('q', q);
|
|
1043
|
+
if (state && String(state).toUpperCase() !== 'ALL') qs.set('state', String(state).toUpperCase());
|
|
1044
|
+
for (const l of [].concat(labels || [])) if (l) qs.append('label', l);
|
|
1045
|
+
if (sort) qs.set('sort', sort);
|
|
1046
|
+
if (pass) qs.set('pass', pass);
|
|
1047
|
+
const tail = qs.toString();
|
|
1048
|
+
return this.request(`${this._publicIssuesBase(slug, blockId)}${tail ? `?${tail}` : ''}`, { noAuth: true });
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* ISSUE-PUBLIC-ATTACH — 공개 트래커(또는 페이지 블록)에 새 이슈·댓글용 파일을 올린다(본문 = 바이트). 10MB·허용 형식은 서버가 판정한다.
|
|
1053
|
+
* 돌려받은 `markdown` 을 이슈·댓글 본문 끝에 붙여 보낸다(`withPublicAttachments`).
|
|
1054
|
+
*/
|
|
1055
|
+
async uploadPublicIssueFile(slug, { blockId, filename, mimeType, data, guestName, pass, asGuest } = {}) {
|
|
1056
|
+
const s = encodeURIComponent(slug);
|
|
1057
|
+
const base = blockId ? `/public/nodes/${s}/issue-blocks/${encodeURIComponent(blockId)}/attachments` : `/public/nodes/${s}/issues/attachments`;
|
|
1058
|
+
const qs = new URLSearchParams({ filename, mimeType: mimeType || publicIssueMimeOf(filename) || 'application/octet-stream' });
|
|
1059
|
+
if (guestName) qs.set('guestName', guestName);
|
|
1060
|
+
if (pass) qs.set('pass', pass);
|
|
1061
|
+
return this.request(`${base}?${qs.toString()}`, {
|
|
1062
|
+
method: 'POST',
|
|
1063
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
1064
|
+
body: data,
|
|
1065
|
+
noAuth: Boolean(asGuest) || !this.apiKey,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
|
|
999
1069
|
/** 공개 트래커(또는 페이지 블록)의 이슈 하나 — 댓글까지. */
|
|
1000
1070
|
async readPublicIssue(slug, number, { blockId, pass } = {}) {
|
|
1001
1071
|
const qs = pass ? `?pass=${encodeURIComponent(pass)}` : '';
|
|
@@ -1509,10 +1579,10 @@ export class RootrClient {
|
|
|
1509
1579
|
});
|
|
1510
1580
|
}
|
|
1511
1581
|
|
|
1512
|
-
async queryVectors(databaseId, column, { vector, topK, minScore } = {}) {
|
|
1582
|
+
async queryVectors(databaseId, column, { vector, topK, minScore, filters } = {}) {
|
|
1513
1583
|
return this.request(`/databases/${encodeURIComponent(databaseId)}/vectors/${encodeURIComponent(column)}/query`, {
|
|
1514
1584
|
method: 'POST',
|
|
1515
|
-
body: { vector, topK, minScore },
|
|
1585
|
+
body: { vector, topK, minScore, ...(filters?.length ? { filters } : {}) },
|
|
1516
1586
|
});
|
|
1517
1587
|
}
|
|
1518
1588
|
|
|
@@ -2452,7 +2452,10 @@ export function registerDatabaseTools(server) {
|
|
|
2452
2452
|
'query with the same model you used for rootr_set_row_vectors. `minScore` drops weak matches. Rows the caller ' +
|
|
2453
2453
|
'cannot see (own-rows-only tables) are never returned. Rows without a stored vector are skipped. ' +
|
|
2454
2454
|
'The column can hold at most 50,000 vectors — a bigger column is refused, not searched partially — and the scan has ' +
|
|
2455
|
-
'no index, so latency grows with the number of rows
|
|
2455
|
+
'no index, so latency grows with the number of rows compared (1536 dims: ~0.7s at 2,000 rows, ~3s at 10,000). ' +
|
|
2456
|
+
'Pass `filters` (same shape as rootr_create_view filters, columns by NAME or id) to compare only the rows that match — ' +
|
|
2457
|
+
'e.g. [{"propId":"상태","op":"is","values":["진행"]}]. When more than 5,000 vectors were compared without filters the ' +
|
|
2458
|
+
'answer carries a `hint` saying so; `scanned` is how many vectors were compared.',
|
|
2456
2459
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
2457
2460
|
inputSchema: {
|
|
2458
2461
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
@@ -2460,13 +2463,21 @@ export function registerDatabaseTools(server) {
|
|
|
2460
2463
|
vector: z.array(z.number()).describe('Query vector — same length as the column\'s dimensions'),
|
|
2461
2464
|
topK: z.number().int().min(1).max(100).optional().default(10).describe('How many rows (default 10, max 100)'),
|
|
2462
2465
|
minScore: z.number().min(-1).max(1).optional().describe('Only rows with cosine ≥ this'),
|
|
2466
|
+
filters: z
|
|
2467
|
+
.array(z.object({ propId: z.string(), op: z.string(), values: z.array(z.string()).optional() }))
|
|
2468
|
+
.optional()
|
|
2469
|
+
.describe(
|
|
2470
|
+
'Compare only rows matching EVERY condition (AND), checked before any vector is compared — the way to keep a big ' +
|
|
2471
|
+
'column fast. Same conditions as a view filter: propId = column NAME or id, op = is / is_not / contains / ' +
|
|
2472
|
+
'is_empty / is_not_empty / before / after / on / between / eq / gt / lt / checked / unchecked / today / this_week …',
|
|
2473
|
+
),
|
|
2463
2474
|
},
|
|
2464
2475
|
},
|
|
2465
|
-
async ({ databaseId, column, vector, topK, minScore }) => {
|
|
2476
|
+
async ({ databaseId, column, vector, topK, minScore, filters }) => {
|
|
2466
2477
|
try {
|
|
2467
2478
|
const client = makeClient();
|
|
2468
2479
|
const id = await resolveNodeId(client, databaseId);
|
|
2469
|
-
return jsonResult(await client.queryVectors(id, column, { vector, topK, minScore }));
|
|
2480
|
+
return jsonResult(await client.queryVectors(id, column, { vector, topK, minScore, filters }));
|
|
2470
2481
|
} catch (err) {
|
|
2471
2482
|
return errorResult(err);
|
|
2472
2483
|
}
|
|
@@ -693,7 +693,10 @@ export function registerDocumentTools(server) {
|
|
|
693
693
|
title: 'Duplicate a Rootr node (deep copy)',
|
|
694
694
|
description:
|
|
695
695
|
'Deep-copy a Rootr (루터) node — a document or a whole folder tree — next to the original, or into ' +
|
|
696
|
-
'another workspace the user can access (targetWorkspaceId). Returns the new root node.'
|
|
696
|
+
'another workspace the user can access (targetWorkspaceId). Returns the new root node. If an older table ' +
|
|
697
|
+
'(or one inside the copied folder) has two columns with the same name, the COPY gets the later one renamed ' +
|
|
698
|
+
'to "Name (2)" (values and views follow — columns are kept by id; the original is not changed) and the ' +
|
|
699
|
+
'result lists it in `renamedColumns`.',
|
|
697
700
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
698
701
|
inputSchema: {
|
|
699
702
|
target: z.string().describe('Node path (starting with "/") or node id to copy'),
|
package/lib/mcp-tools/issues.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
import { textResult, jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT } from './shared.js';
|
|
4
|
+
import {
|
|
5
|
+
RootrApiError,
|
|
6
|
+
publicIssueMimeOf,
|
|
7
|
+
withPublicAttachments,
|
|
8
|
+
PUBLIC_ISSUE_FILE_EXTENSIONS,
|
|
9
|
+
PUBLIC_ISSUE_FILE_MAX_BYTES,
|
|
10
|
+
PUBLIC_ISSUE_FILES_PER_POST,
|
|
11
|
+
} from '../client.js';
|
|
4
12
|
|
|
5
13
|
/**
|
|
6
14
|
* ISSUE-PUBLIC — 외부 참여(웹게시한 트래커·페이지에 끼운 트래커에 밖의 사람이 무엇을 할 수 있나). 서버 값과 같아야 한다
|
|
@@ -253,6 +261,81 @@ export function registerIssueTools(server) {
|
|
|
253
261
|
password: z.string().optional().describe('If the public page is password-locked'),
|
|
254
262
|
};
|
|
255
263
|
|
|
264
|
+
/**
|
|
265
|
+
* ISSUE-PUBLIC-ATTACH — 공개 이슈·댓글에 붙일 파일. **내용(base64)만** 받는다 — 파일 경로는 받지 않는다: 원격 커넥터는 이 도구를 API 서버
|
|
266
|
+
* 안에서 돌리므로 경로를 읽으면 서버 파일을 남의 공개 트래커로 내보내는 구멍이 된다(rootr-remote-mcp-runs-in-api-process). 로컬에서도
|
|
267
|
+
* 같은 모양이라 원격/로컬을 가를 필요가 없다. 크기 상한은 서버와 같은 10MB(base64 로 약 13.4MB), 글 하나에 10개.
|
|
268
|
+
*/
|
|
269
|
+
const PUBLIC_FILES = z
|
|
270
|
+
.array(
|
|
271
|
+
z.object({
|
|
272
|
+
filename: z.string().min(1).max(255).describe('File name with extension, e.g. "screenshot.png"'),
|
|
273
|
+
contentBase64: z
|
|
274
|
+
.string()
|
|
275
|
+
.max(Math.ceil(PUBLIC_ISSUE_FILE_MAX_BYTES / 3) * 4 + 4)
|
|
276
|
+
.describe('The file bytes, base64-encoded (≤10MB decoded)'),
|
|
277
|
+
mimeType: z.string().max(120).optional().describe('Optional — guessed from the extension'),
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
280
|
+
.max(PUBLIC_ISSUE_FILES_PER_POST)
|
|
281
|
+
.optional()
|
|
282
|
+
.describe(
|
|
283
|
+
`Files to attach (≤${PUBLIC_ISSUE_FILES_PER_POST}, ≤10MB each): ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}. ` +
|
|
284
|
+
'Images show inside the text, other files as a download link. svg/html/executables/video are refused, and the file\'s ' +
|
|
285
|
+
'first bytes must match its extension. Content only — no file paths.',
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
/** 파일들을 그 트래커에 올리고, 본문 끝에 붙일 줄들을 돌려준다. 보내기 전에 크기·형식을 거른다(서버가 다시 판정). */
|
|
289
|
+
async function uploadPublicFiles(client, slug, files, opts) {
|
|
290
|
+
const lines = [];
|
|
291
|
+
for (const f of files || []) {
|
|
292
|
+
const data = Buffer.from(f.contentBase64, 'base64');
|
|
293
|
+
if (!data.length) throw new RootrApiError(`빈 파일입니다: ${f.filename}`);
|
|
294
|
+
if (data.length > PUBLIC_ISSUE_FILE_MAX_BYTES) throw new RootrApiError(`파일이 너무 큽니다: ${f.filename} — 한 파일에 10MB까지`);
|
|
295
|
+
const mimeType = f.mimeType || publicIssueMimeOf(f.filename);
|
|
296
|
+
if (!mimeType) {
|
|
297
|
+
throw new RootrApiError(`이 형식은 올릴 수 없습니다: ${f.filename}`, { hint: `허용: ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}` });
|
|
298
|
+
}
|
|
299
|
+
const out = await client.uploadPublicIssueFile(slug, { ...opts, filename: f.filename, mimeType, data });
|
|
300
|
+
lines.push(out.markdown);
|
|
301
|
+
}
|
|
302
|
+
return lines;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
server.registerTool(
|
|
306
|
+
'rootr_search_public_issues',
|
|
307
|
+
{
|
|
308
|
+
title: 'Search the issues of a PUBLISHED tracker (outside view)',
|
|
309
|
+
description:
|
|
310
|
+
'Search/filter the issues of someone\'s (or your own) PUBLISHED Rootr (루터) issue tracker through its public address — ' +
|
|
311
|
+
'what an outside visitor sees. `q` matches title, body, number ("#12" or "12") and, when the tracker shows authors ' +
|
|
312
|
+
'(participation login/anyone), the author name; `labels` = label names (issues having all of them); `state` OPEN|CLOSED; ' +
|
|
313
|
+
'`sort` newest (default) | oldest | comments. Returns { participation, issues:[{number,title,state,body,labels,createdAt,' +
|
|
314
|
+
'closedAt,commentCount,author}], truncated? } (≤500 per call). No key needed. For a tracker in YOUR workspace prefer ' +
|
|
315
|
+
'rootr_list_issues (internal fields).',
|
|
316
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
317
|
+
inputSchema: {
|
|
318
|
+
url: PUBLIC_TARGET.url,
|
|
319
|
+
blockId: PUBLIC_TARGET.blockId,
|
|
320
|
+
password: PUBLIC_TARGET.password,
|
|
321
|
+
q: z.string().max(200).optional().describe('Search text'),
|
|
322
|
+
state: z.enum(['OPEN', 'CLOSED', 'ALL']).optional().describe('Default ALL'),
|
|
323
|
+
labels: z.array(z.string().max(100)).max(20).optional().describe('Label names — issues having all of them'),
|
|
324
|
+
sort: z.enum(['newest', 'oldest', 'comments']).optional(),
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
async ({ url, blockId, password, q, state, labels, sort }) => {
|
|
328
|
+
try {
|
|
329
|
+
const client = makeClient();
|
|
330
|
+
const slug = slugOf(url);
|
|
331
|
+
const pass = password ? await client.unlockPublicNode(slug, password) : undefined;
|
|
332
|
+
return jsonResult(await client.searchPublicIssues(slug, { blockId, q, state, labels, sort, pass }));
|
|
333
|
+
} catch (err) {
|
|
334
|
+
return errorResult(err);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
|
|
256
339
|
server.registerTool(
|
|
257
340
|
'rootr_submit_public_issue',
|
|
258
341
|
{
|
|
@@ -261,22 +344,26 @@ export function registerIssueTools(server) {
|
|
|
261
344
|
'File an issue on someone\'s (or your own) PUBLISHED Rootr (루터) issue tracker through its public address — the ' +
|
|
262
345
|
'way an outside person or an external agent reports a bug/request. Works only when the tracker\'s participation ' +
|
|
263
346
|
'allows it: "login" (your account key counts as signed in) or "anyone". Errors: 403 PUBLIC_READ_ONLY (보기만), ' +
|
|
264
|
-
'401 LOGIN_REQUIRED, 400 GUEST_NAME_REQUIRED, 401 PUBLIC_DOC_PASSWORD (pass `password`). Text only — labels, ' +
|
|
265
|
-
'assignee and state cannot be set from outside, and @mentions are stripped.
|
|
266
|
-
'
|
|
347
|
+
'401 LOGIN_REQUIRED, 400 GUEST_NAME_REQUIRED, 401 PUBLIC_DOC_PASSWORD (pass `password`). Text and `files` only — labels, ' +
|
|
348
|
+
'assignee and state cannot be set from outside, and @mentions are stripped. `files` are uploaded to that tracker first ' +
|
|
349
|
+
'and appended to the body (415 ATTACHMENT_TYPE_NOT_ALLOWED / ATTACHMENT_CONTENT_MISMATCH, 413 ATTACHMENT_TOO_LARGE, ' +
|
|
350
|
+
'403 TRACKER_STORAGE_FULL). Returns { ok, number }. For a tracker in YOUR workspace prefer rootr_create_issue (full fields).',
|
|
267
351
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
268
352
|
inputSchema: {
|
|
269
353
|
...PUBLIC_TARGET,
|
|
270
354
|
title: z.string().max(200).describe('Issue title (≤200)'),
|
|
271
355
|
body: z.string().max(20000).optional().describe('Issue body, markdown (≤20,000)'),
|
|
356
|
+
files: PUBLIC_FILES,
|
|
272
357
|
},
|
|
273
358
|
},
|
|
274
|
-
async ({ url, blockId, guestName, asGuest, password, title, body }) => {
|
|
359
|
+
async ({ url, blockId, guestName, asGuest, password, title, body, files }) => {
|
|
275
360
|
try {
|
|
276
361
|
const client = makeClient();
|
|
277
362
|
const slug = slugOf(url);
|
|
278
363
|
const pass = password ? await client.unlockPublicNode(slug, password) : undefined;
|
|
279
|
-
|
|
364
|
+
const lines = await uploadPublicFiles(client, slug, files, { blockId, guestName, pass, asGuest });
|
|
365
|
+
const full = withPublicAttachments(body || '', lines);
|
|
366
|
+
return jsonResult(await client.submitPublicIssue(slug, { blockId, title, body: full || undefined, guestName, pass, asGuest }));
|
|
280
367
|
} catch (err) {
|
|
281
368
|
return errorResult(err);
|
|
282
369
|
}
|
|
@@ -289,24 +376,26 @@ export function registerIssueTools(server) {
|
|
|
289
376
|
title: 'Comment on an issue of a PUBLISHED tracker (outside participation)',
|
|
290
377
|
description:
|
|
291
378
|
'Comment on issue #number of a PUBLISHED Rootr (루터) issue tracker through its public address (same rules as ' +
|
|
292
|
-
'rootr_submit_public_issue). Pass `read: true` instead of `body` to just read that issue\'s public comments. ' +
|
|
379
|
+
'rootr_submit_public_issue, `files` too). Pass `read: true` instead of `body` to just read that issue\'s public comments. ' +
|
|
293
380
|
'Returns { ok, id } (or { number, comments } when reading).',
|
|
294
381
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
295
382
|
inputSchema: {
|
|
296
383
|
...PUBLIC_TARGET,
|
|
297
384
|
number: z.number().int().positive().describe('Issue number (#N)'),
|
|
298
|
-
body: z.string().max(10000).optional().describe('Comment, markdown (≤10,000). Required unless read is true'),
|
|
385
|
+
body: z.string().max(10000).optional().describe('Comment, markdown (≤10,000). Required unless read is true or files are given'),
|
|
386
|
+
files: PUBLIC_FILES,
|
|
299
387
|
read: z.boolean().optional().describe('Only read the public comments of this issue'),
|
|
300
388
|
},
|
|
301
389
|
},
|
|
302
|
-
async ({ url, blockId, guestName, asGuest, password, number, body, read }) => {
|
|
390
|
+
async ({ url, blockId, guestName, asGuest, password, number, body, files, read }) => {
|
|
303
391
|
try {
|
|
304
392
|
const client = makeClient();
|
|
305
393
|
const slug = slugOf(url);
|
|
306
394
|
const pass = password ? await client.unlockPublicNode(slug, password) : undefined;
|
|
307
395
|
if (read) return jsonResult(await client.readPublicIssue(slug, number, { blockId, pass }));
|
|
308
|
-
if (!body) throw new Error('body is required (or
|
|
309
|
-
|
|
396
|
+
if (!body && !(files && files.length)) throw new Error('body is required (or files, or read: true)');
|
|
397
|
+
const lines = await uploadPublicFiles(client, slug, files, { blockId, guestName, pass, asGuest });
|
|
398
|
+
return jsonResult(await client.commentPublicIssue(slug, number, { blockId, body: withPublicAttachments(body || '', lines), guestName, pass, asGuest }));
|
|
310
399
|
} catch (err) {
|
|
311
400
|
return errorResult(err);
|
|
312
401
|
}
|