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.
Files changed (2) hide show
  1. package/genetics.mjs +111 -33
  2. 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
- * 덜 다친다. 어느 쪽으로 나가는지는 `whoami` 가 말해 주고, 실패문에도 적는다.
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 credential() {
168
- if (KEY) return { kind: 'key', token: KEY };
174
+ /** 이 기계에 심긴 사람 자격(없으면 null). */
175
+ function userCred() {
169
176
  const c = credRead();
170
- if (c) return { kind: 'user', token: c.token, user: c.user, expires_at: c.expires_at };
171
- return null;
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
- async function api(method, pathname, body) {
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 res = await fetch(BASE + pathname, {
469
- method,
470
- headers: { Authorization: `Bearer ${cred.token}`, 'Content-Type': 'application/json' },
471
- body: body === undefined ? undefined : JSON.stringify(body),
472
- });
473
- const text = await res.text();
474
- let data;
475
- try { data = JSON.parse(text); } catch { throw new Error(`응답을 해석하지 못했습니다(HTTP ${res.status}): ${text.slice(0, 300)}`); }
476
- if (!res.ok || data.ok === false) {
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
- throw new Error(`${data.error || `HTTP ${res.status}`}\n→ \`login\` 도구로 다시 로그인하세요.`);
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 ${res.status}`);
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
- const other = credRead();
1682
- const also = other ? `\n(로그인 자격도 있습니다: ${other.user?.email} — 키가 있어 그쪽이 안 쓰입니다.)` : '';
1683
- return `고객사 키로 붙어 있습니다. 키가 곧 고객사이므로 그 고객사에만 발행됩니다.${also}`;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",