genetics-mcp 2.0.0 → 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 +166 -35
  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
  // ── 도구 ────────────────────────────────────────────────────────────
@@ -704,6 +774,34 @@ function maskSecrets(text) {
704
774
  }
705
775
 
706
776
  /** 트랜스크립트를 검열한다. 서버도 같은 일을 다시 한다 — 클라이언트는 낡을 수 있다. */
777
+ /**
778
+ * **아는 자리 밖도 걷는다.** 구조를 아는 셋(`toolUseResult`·`cwd`·`message.content`)만 걷던
779
+ * 동안, 하네스가 만드는 다른 레코드(`queue-operation` 등)는 한 글자도 안 걷혔다 — 형제 세션이
780
+ * 그 줄에 살아남은 값 때문에 **세션을 영영 못 올리는** 상태로 막혔다(2026-08-21).
781
+ *
782
+ * 나쁜 쪽이 둘이다: 서버 관문이 잡으면 그 대화는 통째로 못 올라가고, 관문이 없거나 지나치면
783
+ * 비밀이 망을 건너간다. 그래서 구조를 아는 자리를 다 걷은 **뒤에** 한 번 더 훑는다.
784
+ *
785
+ * 줄 전체에 정규식을 돌리지 않고 **문자열 값만 재귀로** 훑는 이유: 줄에는 JSON 이스케이프가
786
+ * 섞여 있어, 가리려다 따옴표 짝을 깨면 그건 검열이 아니라 **파손**이다. 값만 바꾸면 모양이
787
+ * 안 깨진다. 키 이름은 안 건드린다 — 비밀은 값에 있지 이름에 있지 않다.
788
+ */
789
+ function maskDeep(v) {
790
+ if (typeof v === 'string') return maskSecrets(v);
791
+ if (Array.isArray(v)) {
792
+ let n = 0;
793
+ const out = v.map((x) => { const [y, k] = maskDeep(x); n += k; return y; });
794
+ return [out, n];
795
+ }
796
+ if (v && typeof v === 'object') {
797
+ let n = 0;
798
+ const out = {};
799
+ for (const [k, x] of Object.entries(v)) { const [y, c] = maskDeep(x); out[k] = y; n += c; }
800
+ return [out, n];
801
+ }
802
+ return [v, 0];
803
+ }
804
+
707
805
  function scrubTranscript(raw) {
708
806
  const out = [];
709
807
  let redacted = 0, dropped = 0, no = 0;
@@ -735,7 +833,10 @@ function scrubTranscript(raw) {
735
833
  }
736
834
  }
737
835
  }
738
- out.push(JSON.stringify(r));
836
+ // ★ 마지막 그물 — 위에서 못 본 레코드 종류의 값까지 여기서 걷는다(위 maskDeep 주석 참조).
837
+ const [safe, extra] = maskDeep(r);
838
+ redacted += extra;
839
+ out.push(JSON.stringify(safe));
739
840
  }
740
841
  if (!out.length) throw new Error('빈 트랜스크립트입니다.');
741
842
  return { jsonl: out.join('\n') + '\n', lines: out.length, dropped, redacted };
@@ -815,6 +916,16 @@ function splitAtLines(text, max) {
815
916
  *
816
917
  * @return {{r: object, before: number}} 서버 응답과, 검열 전 파일 크기
817
918
  */
919
+ /**
920
+ * 이 대화를 **무엇이** 만들었는가. 판번호만으로는 세션끼리 구별이 안 되므로 클라이언트를 함께 싣는다.
921
+ * 값이 없으면 키를 안 만든다 — 없는 것을 「알 수 없음」으로 적으면 그 값이 다시 상수가 된다.
922
+ */
923
+ function toolsStamp() {
924
+ const t = { mcp: VERSION };
925
+ if (CLIENT?.name) t.client = CLIENT.version ? `${CLIENT.name}/${CLIENT.version}` : String(CLIENT.name);
926
+ return t;
927
+ }
928
+
818
929
  async function pushSession(tenant, uuid, title, dryRun) {
819
930
  const f = findTranscript(uuid);
820
931
  if (!f) throw new Error(`이 기계에 그 세션이 없습니다: ${uuid}`);
@@ -830,7 +941,7 @@ async function pushSession(tenant, uuid, title, dryRun) {
830
941
  const body = {
831
942
  tenant, uuid,
832
943
  origin_cwd: process.cwd().replace(new RegExp(`^${os.homedir()}`), '~'),
833
- origin_git: gitHere(), tools: { mcp: VERSION },
944
+ origin_git: gitHere(), tools: toolsStamp(),
834
945
  jsonl: sc.jsonl,
835
946
  };
836
947
  if (typeof title === 'string' && title.trim() !== '') body.title = title.trim();
@@ -1213,6 +1324,9 @@ async function autoPushIfAhead(reply, recs) {
1213
1324
  }
1214
1325
  }
1215
1326
 
1327
+ /** 붙어 있는 에이전트(initialize 의 clientInfo). 세션 이식에서 「어느 도구가 만든 대화인가」다. */
1328
+ let CLIENT = null;
1329
+
1216
1330
  let beatTimer = null;
1217
1331
  let beatLastSent = 0;
1218
1332
  let beatLastSig = '';
@@ -1634,13 +1748,21 @@ async function callTool(name, args = {}) {
1634
1748
  const c = credential();
1635
1749
  if (!c) return `자격이 없습니다. \`login\` 을 부르거나 ${ENV_FILE} 에 키를 넣으세요.`;
1636
1750
  if (c.kind === 'key') {
1637
- const other = credRead();
1638
- const also = other ? `\n(로그인 자격도 있습니다: ${other.user?.email} — 키가 있어 그쪽이 안 쓰입니다.)` : '';
1639
- return `고객사 키로 붙어 있습니다. 키가 곧 고객사이므로 그 고객사에만 발행됩니다.${also}`;
1751
+ // 여기 오는 것은 **사람 자격이 없을 때뿐**이다(credential 은 이제 사람을 먼저 쥔다).
1752
+ return '소속처 키로 붙어 있습니다. 키 하나가 소속처 하나라 그곳에만 발행됩니다.\n'
1753
+ + '세션과 지식(`resume_session`·`search_cases` 등)은 서버가 키를 받지 않습니다 — '
1754
+ + '쓰시려면 `login` 으로 SSO 로그인하세요.';
1640
1755
  }
1756
+ /*
1757
+ * 사람으로 붙었는데 키도 있으면 **그 사실을 말한다.** 발행만 키로 나가므로,
1758
+ * 안 말하면 「왜 이 소속처로 발행됐지」를 사람이 짐작해야 한다.
1759
+ */
1760
+ const alsoKey = keyCred()
1761
+ ? '\n(키도 있습니다 — **발행 다섯만** 키로 나가고 나머지는 이 신원으로 갑니다.)'
1762
+ : '';
1641
1763
  const me = await api('GET', '/api/me');
1642
1764
  return `${me.user.name}(${me.user.email}) · ${me.user.role} 으로 붙어 있습니다.\n`
1643
- + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}`;
1765
+ + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
1644
1766
  }
1645
1767
 
1646
1768
  case 'logout': {
@@ -1729,7 +1851,7 @@ async function callTool(name, args = {}) {
1729
1851
  const body = { ...data, tenant: args.tenant, slug: args.slug, publish };
1730
1852
  if (args.published_at) body.published_at = args.published_at;
1731
1853
  if (args.dry_run) body.dry_run = true;
1732
- const r = await api('POST', '/api/reports', body);
1854
+ const r = await api('POST', '/api/reports', body, PUBLISH);
1733
1855
  if (r.dry_run) {
1734
1856
  return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
1735
1857
  + `${r.tenant} · ${r.title} (건 ${r.cases}) · 저장하면 ${r.would_publish ? '바로 공개' : '초안'}`
@@ -1746,20 +1868,20 @@ async function callTool(name, args = {}) {
1746
1868
  case 'release_report': {
1747
1869
  const body = { tenant: args.tenant };
1748
1870
  if (args.published_at) body.published_at = args.published_at;
1749
- 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);
1750
1872
  return (r.changed ? '열었습니다 — 고객이 지금부터 볼 수 있습니다.' : '이미 열려 있습니다.')
1751
1873
  + `\n${r.url}`;
1752
1874
  }
1753
1875
  case 'unpublish_report': {
1754
1876
  const body = { tenant: args.tenant };
1755
1877
  if (args.reason) body.reason = args.reason;
1756
- 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);
1757
1879
  return (r.changed
1758
1880
  ? `내렸습니다 — 고객 목록과 열람에서 빠졌습니다(문서와 이력은 그대로).`
1759
1881
  : '이미 내려가 있습니다.') + `\n다시 열려면 release_report(id: ${Number(args.id)}).`;
1760
1882
  }
1761
1883
  case 'report_assets': {
1762
- 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);
1763
1885
  if (!r.total) return '이 문서에는 서버가 채울 그림이 없습니다.';
1764
1886
  const mark = { done: '도착', failed: '실패', queued: '그리는 중', waiting: '차례 기다림' };
1765
1887
  const lines = (r.slots || []).map((s) =>
@@ -1771,7 +1893,7 @@ async function callTool(name, args = {}) {
1771
1893
  const data = await materializeAssets(args.data);
1772
1894
  const payload = { ...data, tenant: args.tenant };
1773
1895
  if (args.dry_run) payload.dry_run = true;
1774
- const r = await api('PUT', `/api/reports/${Number(args.id)}`, payload);
1896
+ const r = await api('PUT', `/api/reports/${Number(args.id)}`, payload, PUBLISH);
1775
1897
  if (r.dry_run) {
1776
1898
  return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + warnText(r);
1777
1899
  }
@@ -1980,6 +2102,15 @@ async function handle(line) {
1980
2102
  try {
1981
2103
  let result;
1982
2104
  if (req.method === 'initialize') {
2105
+ /*
2106
+ * ★ **누가 붙었는지 적어 둔다.** 세션을 올릴 때 함께 실어 보낸다(`tools.client`).
2107
+ * 왜 필요한가: `tools` 에 판번호(`{mcp:"2.0.0"}`)만 실으면 **같은 판을 쓰는 모든 세션이
2108
+ * 같은 값**이라 아무것도 구별하지 못한다 — 세션을 잇는 쪽에서 그것이 잡음으로 잡혔다
2109
+ * (실측 +0.87비트). 도구 **이름 집합**을 실어도 마찬가지다: 그 목록은 판마다 고정이라
2110
+ * 역시 상수다. 실제로 갈리는 것은 **어느 에이전트가 붙었는가**이고, 그 값은 여기
2111
+ * initialize 의 clientInfo 로만 온다. 놓치면 다시 물어볼 자리가 없다.
2112
+ */
2113
+ CLIENT = req.params?.clientInfo || null;
1983
2114
  result = {
1984
2115
  protocolVersion: req.params?.protocolVersion || '2024-11-05',
1985
2116
  capabilities: { tools: {} },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",