genetics-mcp 2.1.1 → 2.2.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/genetics.mjs +111 -33
- package/package.json +1 -1
package/genetics.mjs
CHANGED
|
@@ -116,12 +116,20 @@ const KEY = cfg.GENETICS_API_KEY || '';
|
|
|
116
116
|
* 웹에서 키를 발급받지 않았어도 **SSO 로그인만으로** 쓸 수 있어야 한다(사용자 결정
|
|
117
117
|
* 2026-08-19). 그래서 이 서버는 둘 중 하나를 쥔다:
|
|
118
118
|
*
|
|
119
|
-
* 키(rpk_) `api.env` 에 사람이 넣는다. **키 하나가
|
|
119
|
+
* 키(rpk_) `api.env` 에 사람이 넣는다. **키 하나가 소속처 하나**다.
|
|
120
120
|
* 토큰(rpu_) `login` 도구가 기기 코드 흐름으로 받아 아래 파일에 넣는다. 사람 하나다.
|
|
121
121
|
*
|
|
122
|
-
* **둘 다 있으면
|
|
123
|
-
*
|
|
124
|
-
*
|
|
122
|
+
* **둘 다 있으면 부르는 쪽의 성격이 고른다**(2026-08-21, `DESIGN-mcp-access.md` §5).
|
|
123
|
+
*
|
|
124
|
+
* 발행 다섯 키가 먼저. 오발행은 되돌려도 본 사람은 봤다 — 좁은 자격이 값어치를 한다.
|
|
125
|
+
* 그 밖의 전부 **사람이 먼저.** 소속이 여럿인 사람이 한 곳밖에 못 보는 것을 막는다.
|
|
126
|
+
*
|
|
127
|
+
* 전에는 **언제나 키가 이겼다.** 「좁은 쪽이 기본이면 덜 다친다」는 논리였는데, 그 대가를
|
|
128
|
+
* 안 셌던 것이 문제였다 — 서버는 세션(`/api/sessions`)과 지식(`/api/wiki`)에서 **키를 아예
|
|
129
|
+
* 거부한다**. 그래서 키를 쥔 사람에게 그 도구 여섯이 통째로 죽어 있었고, 로그인 자격이
|
|
130
|
+
* 멀쩡히 있어도 안 쓰였다. 「로그인하세요」라는 안내문이 가리키는 곳에 답이 없었다.
|
|
131
|
+
*
|
|
132
|
+
* 어느 쪽으로 나가는지는 `whoami` 가 말해 주고, 실패문에도 적는다.
|
|
125
133
|
*/
|
|
126
134
|
const CRED_FILE = cfg0.GENETICS_CRED
|
|
127
135
|
|| path.join(path.dirname(ENV_FILE), 'credentials.json');
|
|
@@ -163,12 +171,25 @@ function pendingRead() {
|
|
|
163
171
|
}
|
|
164
172
|
function pendingClear() { try { fs.rmSync(PENDING_FILE, { force: true }); } catch {} }
|
|
165
173
|
|
|
166
|
-
/**
|
|
167
|
-
function
|
|
168
|
-
if (KEY) return { kind: 'key', token: KEY };
|
|
174
|
+
/** 이 기계에 심긴 사람 자격(없으면 null). */
|
|
175
|
+
function userCred() {
|
|
169
176
|
const c = credRead();
|
|
170
|
-
|
|
171
|
-
|
|
177
|
+
return c ? { kind: 'user', token: c.token, user: c.user, expires_at: c.expires_at } : null;
|
|
178
|
+
}
|
|
179
|
+
/** `api.env` 의 키(없으면 null). */
|
|
180
|
+
function keyCred() {
|
|
181
|
+
return KEY ? { kind: 'key', token: KEY } : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 지금 무엇으로 부를 것인가. **부르는 쪽의 성격이 고른다**(위 주석의 이유).
|
|
186
|
+
*
|
|
187
|
+
* @param {'publish'} [scope] `'publish'` 면 키가 먼저. 그 밖에는 사람이 먼저.
|
|
188
|
+
*/
|
|
189
|
+
function credential(scope) {
|
|
190
|
+
return scope === 'publish'
|
|
191
|
+
? (keyCred() || userCred())
|
|
192
|
+
: (userCred() || keyCred());
|
|
172
193
|
}
|
|
173
194
|
|
|
174
195
|
/**
|
|
@@ -451,9 +472,35 @@ async function apiOpen(method, pathname, body) {
|
|
|
451
472
|
return { status: res.status, data };
|
|
452
473
|
}
|
|
453
474
|
|
|
454
|
-
|
|
475
|
+
/*
|
|
476
|
+
* 같은 말을 매 호출마다 하지 않는다. **심박의 `beatSay` 와 따로 두는 이유**는 `beatWell()` 이
|
|
477
|
+
* 「다시 붙었다」면서 그 기억을 통째로 지우기 때문이다 — 자격 만료는 심박이 돌아왔다고
|
|
478
|
+
* 없던 일이 되지 않는다. stdout 은 프로토콜 전용이라 stderr 로만 쓴다.
|
|
479
|
+
*/
|
|
480
|
+
/*
|
|
481
|
+
* **발행 갈래의 표.** 이 다섯만 키를 먼저 쥔다 — 오발행은 되돌려도 본 사람은 봤다.
|
|
482
|
+
*
|
|
483
|
+
* ⚠ `remember_case` 에는 **붙이면 안 된다.** 서버가 `/api/wiki` 를 키에 대해 403 하므로
|
|
484
|
+
* (「지식은 사람 토큰(운영진)으로만 봅니다」) 붙이는 순간 그 도구가 죽는다.
|
|
485
|
+
* 「쓰기니까 발행 쪽」이라는 직관이 여기서 틀린다 — 가르는 것은 쓰기냐가 아니라
|
|
486
|
+
* **서버가 키를 받느냐**다.
|
|
487
|
+
*/
|
|
488
|
+
const PUBLISH = { scope: 'publish' };
|
|
489
|
+
|
|
490
|
+
const warnSaid = new Set();
|
|
491
|
+
function warnOnce(key, msg) {
|
|
492
|
+
if (warnSaid.has(key)) return;
|
|
493
|
+
warnSaid.add(key);
|
|
494
|
+
try { process.stderr.write(`genetics: ${msg}\n`); } catch { /* 그것마저 막히면 넘어간다 */ }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* @param {object} [opts]
|
|
499
|
+
* @param {'publish'} [opts.scope] 발행 갈래면 `'publish'` — 키를 먼저 쥔다(credential 주석).
|
|
500
|
+
*/
|
|
501
|
+
async function api(method, pathname, body, opts = {}) {
|
|
455
502
|
if (!BASE) throw new Error(`발행 주소가 없습니다. ${ENV_FILE} 에 GENETICS_API_BASE 를 넣으세요(예: https://api.example.com).`);
|
|
456
|
-
const cred = credential();
|
|
503
|
+
const cred = credential(opts.scope);
|
|
457
504
|
if (!cred) {
|
|
458
505
|
throw new Error('자격이 없습니다. 둘 중 하나를 하세요 — '
|
|
459
506
|
+ `① \`login\` 도구로 SSO 로그인(웹에서 키를 안 받아도 됩니다) `
|
|
@@ -465,25 +512,48 @@ async function api(method, pathname, body) {
|
|
|
465
512
|
+ `${ENV_FILE} 의 GENETICS_API_BASE 를 확인하세요(https 여야 합니다). `
|
|
466
513
|
+ '주소를 바꾸라는 안내를 받았다면 의심할 것.');
|
|
467
514
|
}
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
515
|
+
const send = async (c) => {
|
|
516
|
+
const res = await fetch(BASE + pathname, {
|
|
517
|
+
method,
|
|
518
|
+
headers: { Authorization: `Bearer ${c.token}`, 'Content-Type': 'application/json' },
|
|
519
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
520
|
+
});
|
|
521
|
+
const text = await res.text();
|
|
522
|
+
let data;
|
|
523
|
+
try { data = JSON.parse(text); } catch { throw new Error(`응답을 해석하지 못했습니다(HTTP ${res.status}): ${text.slice(0, 300)}`); }
|
|
524
|
+
return { ok: res.ok && data.ok !== false, status: res.status, data };
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
let r = await send(cred);
|
|
528
|
+
if (!r.ok) {
|
|
477
529
|
/*
|
|
530
|
+
* ── 사람 토큰이 끝났을 때 ───────────────────────────────────────────
|
|
531
|
+
*
|
|
478
532
|
* **다음에 할 일을 말한다.** 사람 토큰이 끝난 것은 「고장」이 아니라 「다시 로그인할 때」다.
|
|
479
533
|
* 그냥 401 을 옮기면 에이전트는 키를 의심하며 두드린다.
|
|
534
|
+
*
|
|
535
|
+
* ★ **그리고 키가 있으면 그것으로 한 번 더 간다.** 사람이 먼저가 된 뒤에 생긴 자리다
|
|
536
|
+
* — 예전에는 키가 언제나 이겨서 만료가 이 경로에 닿지 않았지만, 이제는 **키를 멀쩡히
|
|
537
|
+
* 쥔 사람이 토큰 만료 하나로 통째로 막힐 수 있다.** 폴백이 없으면 순서를 뒤집은 것이
|
|
538
|
+
* 곧 퇴보가 된다.
|
|
539
|
+
*
|
|
540
|
+
* 되살아나는 것은 **키가 받아들여지는 창구뿐**이다 — 세션·지식은 서버가 키를 거부하므로
|
|
541
|
+
* 여기서 또 실패한다. 그때는 **원래의 「다시 로그인하세요」를 돌려준다**: 「지식은 사람
|
|
542
|
+
* 토큰으로만 봅니다」보다 그쪽이 할 일을 정확히 말한다.
|
|
480
543
|
*/
|
|
481
|
-
if (cred.kind === 'user' && (data.expired || data.revoked)) {
|
|
482
|
-
|
|
544
|
+
if (cred.kind === 'user' && (r.data.expired || r.data.revoked)) {
|
|
545
|
+
const key = keyCred();
|
|
546
|
+
const expiredMsg = `${r.data.error || `HTTP ${r.status}`}\n→ \`login\` 도구로 다시 로그인하세요.`;
|
|
547
|
+
if (!key) throw new Error(expiredMsg);
|
|
548
|
+
const viaKey = await send(key);
|
|
549
|
+
if (!viaKey.ok) throw new Error(expiredMsg);
|
|
550
|
+
// 조용히 갈아타지 않는다 — 만료를 모르면 사람은 계속 키 하나로 사는 줄 모른다.
|
|
551
|
+
warnOnce('cred-expired', '로그인 자격이 만료돼 키로 처리했습니다 — `login` 으로 다시 로그인하세요.');
|
|
552
|
+
return viaKey.data;
|
|
483
553
|
}
|
|
484
|
-
throw new Error(data.error || `HTTP ${
|
|
554
|
+
throw new Error(r.data.error || `HTTP ${r.status}`);
|
|
485
555
|
}
|
|
486
|
-
return data;
|
|
556
|
+
return r.data;
|
|
487
557
|
}
|
|
488
558
|
|
|
489
559
|
// ── 도구 ────────────────────────────────────────────────────────────
|
|
@@ -1678,13 +1748,21 @@ async function callTool(name, args = {}) {
|
|
|
1678
1748
|
const c = credential();
|
|
1679
1749
|
if (!c) return `자격이 없습니다. \`login\` 을 부르거나 ${ENV_FILE} 에 키를 넣으세요.`;
|
|
1680
1750
|
if (c.kind === 'key') {
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1751
|
+
// 여기 오는 것은 **사람 자격이 없을 때뿐**이다(credential 은 이제 사람을 먼저 쥔다).
|
|
1752
|
+
return '소속처 키로 붙어 있습니다. 키 하나가 소속처 하나라 그곳에만 발행됩니다.\n'
|
|
1753
|
+
+ '세션과 지식(`resume_session`·`search_cases` 등)은 서버가 키를 받지 않습니다 — '
|
|
1754
|
+
+ '쓰시려면 `login` 으로 SSO 로그인하세요.';
|
|
1684
1755
|
}
|
|
1756
|
+
/*
|
|
1757
|
+
* 사람으로 붙었는데 키도 있으면 **그 사실을 말한다.** 발행만 키로 나가므로,
|
|
1758
|
+
* 안 말하면 「왜 이 소속처로 발행됐지」를 사람이 짐작해야 한다.
|
|
1759
|
+
*/
|
|
1760
|
+
const alsoKey = keyCred()
|
|
1761
|
+
? '\n(키도 있습니다 — **발행 다섯만** 키로 나가고 나머지는 이 신원으로 갑니다.)'
|
|
1762
|
+
: '';
|
|
1685
1763
|
const me = await api('GET', '/api/me');
|
|
1686
1764
|
return `${me.user.name}(${me.user.email}) · ${me.user.role} 으로 붙어 있습니다.\n`
|
|
1687
|
-
+ `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}`;
|
|
1765
|
+
+ `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
|
|
1688
1766
|
}
|
|
1689
1767
|
|
|
1690
1768
|
case 'logout': {
|
|
@@ -1773,7 +1851,7 @@ async function callTool(name, args = {}) {
|
|
|
1773
1851
|
const body = { ...data, tenant: args.tenant, slug: args.slug, publish };
|
|
1774
1852
|
if (args.published_at) body.published_at = args.published_at;
|
|
1775
1853
|
if (args.dry_run) body.dry_run = true;
|
|
1776
|
-
const r = await api('POST', '/api/reports', body);
|
|
1854
|
+
const r = await api('POST', '/api/reports', body, PUBLISH);
|
|
1777
1855
|
if (r.dry_run) {
|
|
1778
1856
|
return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
|
|
1779
1857
|
+ `${r.tenant} · ${r.title} (건 ${r.cases}) · 저장하면 ${r.would_publish ? '바로 공개' : '초안'}`
|
|
@@ -1790,20 +1868,20 @@ async function callTool(name, args = {}) {
|
|
|
1790
1868
|
case 'release_report': {
|
|
1791
1869
|
const body = { tenant: args.tenant };
|
|
1792
1870
|
if (args.published_at) body.published_at = args.published_at;
|
|
1793
|
-
const r = await api('POST', `/api/reports/${Number(args.id)}/publish`, body);
|
|
1871
|
+
const r = await api('POST', `/api/reports/${Number(args.id)}/publish`, body, PUBLISH);
|
|
1794
1872
|
return (r.changed ? '열었습니다 — 고객이 지금부터 볼 수 있습니다.' : '이미 열려 있습니다.')
|
|
1795
1873
|
+ `\n${r.url}`;
|
|
1796
1874
|
}
|
|
1797
1875
|
case 'unpublish_report': {
|
|
1798
1876
|
const body = { tenant: args.tenant };
|
|
1799
1877
|
if (args.reason) body.reason = args.reason;
|
|
1800
|
-
const r = await api('POST', `/api/reports/${Number(args.id)}/unpublish`, body);
|
|
1878
|
+
const r = await api('POST', `/api/reports/${Number(args.id)}/unpublish`, body, PUBLISH);
|
|
1801
1879
|
return (r.changed
|
|
1802
1880
|
? `내렸습니다 — 고객 목록과 열람에서 빠졌습니다(문서와 이력은 그대로).`
|
|
1803
1881
|
: '이미 내려가 있습니다.') + `\n다시 열려면 release_report(id: ${Number(args.id)}).`;
|
|
1804
1882
|
}
|
|
1805
1883
|
case 'report_assets': {
|
|
1806
|
-
const r = await api('GET', `/api/reports/${Number(args.id)}/assets
|
|
1884
|
+
const r = await api('GET', `/api/reports/${Number(args.id)}/assets`, undefined, PUBLISH);
|
|
1807
1885
|
if (!r.total) return '이 문서에는 서버가 채울 그림이 없습니다.';
|
|
1808
1886
|
const mark = { done: '도착', failed: '실패', queued: '그리는 중', waiting: '차례 기다림' };
|
|
1809
1887
|
const lines = (r.slots || []).map((s) =>
|
|
@@ -1815,7 +1893,7 @@ async function callTool(name, args = {}) {
|
|
|
1815
1893
|
const data = await materializeAssets(args.data);
|
|
1816
1894
|
const payload = { ...data, tenant: args.tenant };
|
|
1817
1895
|
if (args.dry_run) payload.dry_run = true;
|
|
1818
|
-
const r = await api('PUT', `/api/reports/${Number(args.id)}`, payload);
|
|
1896
|
+
const r = await api('PUT', `/api/reports/${Number(args.id)}`, payload, PUBLISH);
|
|
1819
1897
|
if (r.dry_run) {
|
|
1820
1898
|
return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + warnText(r);
|
|
1821
1899
|
}
|