deel-local-cli 1.12.0 → 1.13.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.
@@ -1,395 +1,407 @@
1
- /**
2
- * MCP(Model Context Protocol) 서버 붙이기 — stdio 규격.
3
- *
4
- * 무엇인가:
5
- * 도구를 **코드를 안 고치고** 밖에서 붙이는 규격이다. 사내 위키 검색기,
6
- * 사내 이슈 트래커, DB 조회기 같은 것을 각 팀이 MCP 서버로 만들어 두면
7
- * deel 은 그걸 그대로 도구로 쓴다. 우리가 매번 도구를 새로 만들지 않아도 된다.
8
- *
9
- * 왜 의존성 없이 되나:
10
- * stdio 규격은 자식 프로세스의 stdin/stdout 에 **줄 단위 JSON-RPC 2.0** 을
11
- * 주고받는 것이 전부다. child_process 와 JSON 이면 된다. SDK 가 필요 없다.
12
- *
13
- * ── 안전에 대해 ────────────────────────────────────────────────────────
14
- *
15
- * MCP 서버는 **남의 프로그램**이다. 이 프로젝트가 존재하는 이유가 '미승인 SW
16
- * 반입 금지' 인데, MCP 를 아무렇게나 켜면 그 선을 우리 손으로 무너뜨리는 셈이다.
17
- * 그래서:
18
- *
19
- * 1) **기본은 꺼져 있다.** .deel/mcp.json 에 사람이 직접 적어야만 뜬다.
20
- * 2) **--offline 이면 아예 안 띄운다.** 자식 프로세스가 어디로 나가는지
21
- * 우리는 못 막는다. 막을 수 없는 것을 막았다고 말하지 않는다.
22
- * 3) **감사기록에 남긴다.** 무엇을 띄웠고 무엇을 불렀는지.
23
- * 4) **작업 범위 밖이다.** MCP 서버는 우리 scope 를 안 지킨다 —
24
- * 제 마음대로 파일을 읽고 쓸 수 있다. /mcp 화면에서 그렇다고 말한다.
25
- */
26
- import { spawn } from 'node:child_process';
27
- import { existsSync, readFileSync } from 'node:fs';
28
- import { join } from 'node:path';
29
- import { VERSION } from '../version.js';
30
-
31
- // 붙는 데 이만큼 넘게 걸리면 포기한다. 시작이 느려지면 안 쓰게 된다.
32
- export const 붙기제한 = 8000;
33
- // 도구 하나 부르고 이만큼 기다린다.
34
- export const 부르기제한 = 60000;
35
- // 서버에서 받을 도구 수. 스키마가 통째로 매 요청에 실리므로 무한정 받으면
36
- // 컨텍스트가 조용히 줄어든다. 넘으면 **넘었다고 말하고** 자른다.
37
- export const 도구최대 = 24;
38
- // 줄(JSON 통)의 최대 크기. 미친 서버가 stdout 을 쏟아부어도 안 죽게.
39
- const 줄최대 = 4 * 1024 * 1024;
40
-
41
- export const 설정자리 = (root) => join(root, '.deel', 'mcp.json');
42
-
43
- /**
44
- * 설정을 읽는다. Claude Code 의 `mcpServers` 모양을 그대로 받는다 —
45
- * 이미 쓰던 설정을 복사해 붙일 있어야 한다.
46
- */
47
- export function 설정읽기(root) {
48
- const p = 설정자리(root);
49
- if (!existsSync(p)) return { 서버들: [], 자리: p, 있음: false };
50
- let j;
51
- try { j = JSON.parse(readFileSync(p, 'utf8')); } catch (e) {
52
- return { 서버들: [], 자리: p, 있음: true, 오류: `mcp.json 을 못 읽었습니다: ${e.message}` };
53
- }
54
- const 표 = j.mcpServers ?? j.servers ?? {};
55
- const 서버들 = [];
56
- for (const [이름, v] of Object.entries(표)) {
57
- if (v?.disabled === true) continue;
58
- // stdio 받는다. http/sse 규격은 바깥으로 나가는 것이라 자물쇠와 부딪힌다.
59
- if (v?.type && v.type !== 'stdio') continue;
60
- if (!v?.command) continue;
61
- 서버들.push({
62
- 이름,
63
- command: String(v.command),
64
- args: Array.isArray(v.args) ? v.args.map(String) : [],
65
- env: v.env && typeof v.env === 'object' ? v.env : null,
66
- cwd: v.cwd ? String(v.cwd) : root,
67
- });
68
- }
69
- return { 서버들, 자리: p, 있음: true };
70
- }
71
-
72
- /*
73
- * ── 지금 띄워 둔 서버들 ─────────────────────────────────────────────────
74
- *
75
- * 여기 왜 명부가 있나. MCP 서버는 붙인 쪽(repl·ACP)이 들고 있을 뿐이라,
76
- * 프로그램이 어느 길로든 끝나 버리면 **아무도 닫는다.** 그러면 사람
77
- * 컴퓨터에 서버 프로세스가 하나씩 쌓인다 작업 관리자를 열기 전에는
78
- * 모르는 종류의 탈이다.
79
- *
80
- * 일감(tools/jobs.js)과 언어 서버(lsp/client.js)에는 이미 이 그물이 있는데
81
- * 여기만 없었다. 같은 자리, 같은 규칙으로 둔다.
82
- */
83
- const 띄운것들 = new Set();
84
-
85
- /** 검사와 진단이 본다. 지금 살아 있는 서버 수. */
86
- export function 살아있는수() { return 띄운것들.size; }
87
-
88
- /**
89
- * 다 닫는다. 프로그램이 끝날 때와 검사 뒤에 부른다.
90
- * @returns {number} 닫은 개수
91
- */
92
- export function 모두닫기() {
93
- const 것들 = [...띄운것들];
94
- 띄운것들.clear();
95
- let n = 0;
96
- for (const s of 것들) { try { s.닫기(); n++; } catch { /* 끝나는 중이라 할 수 있는 게 없다 */ } }
97
- return n;
98
- }
99
-
100
- /*
101
- * 어떤 길로 끝나든 남기지 않는다. 붙인 쪽이 이미 닫았어도 무해하다.
102
- *
103
- * 이름 있는 함수를 그대로 건다 — 이름 없는 화살표로 걸면 그물이 걸려 있는지
104
- * 검사가 밖에서 확인할 길이 없다. 걷어내도 아무도 모르는 그물은 없는 것과 같다.
105
- */
106
- process.once('exit', 모두닫기);
107
-
108
- /**
109
- * 서버 하나와의 연결.
110
- *
111
- * 규격은 JSON-RPC 2.0 이다. 줄 하나에 통 하나 — 그래서 줄 단위로 자르면 된다.
112
- */
113
- export class MCP서버 {
114
- constructor(설정) {
115
- this.이름 = 설정.이름;
116
- this.설정 = 설정;
117
- this.kid = null;
118
- this.다음번호 = 1;
119
- this.기다리는것 = new Map();
120
- this.찌꺼기 = '';
121
- this.도구 = [];
122
- this.정보 = null;
123
- this.죽음 = null; // 왜 죽었나 (사람에게 보여 줄 말)
124
- this.잘림 = 0; // 도구최대 넘어 자른 개수
125
- }
126
-
127
- 살아있나() { return !!this.kid && this.kid.exitCode === null && !this.죽음; }
128
-
129
- async 붙기({ timeout = 붙기제한 } = {}) {
130
- try {
131
- this.kid = spawn(this.설정.command, this.설정.args, {
132
- cwd: this.설정.cwd,
133
- // 설정에 적힌 env 만 얹는다. 우리 환경변수를 통째로 넘기면
134
- // 게이트웨이 열쇠(DEEL_*)까지 남의 프로세스로 넘어간다.
135
- env: { ...깨끗한환경(), ...(this.설정.env ?? {}) },
136
- stdio: ['pipe', 'pipe', 'pipe'],
137
- windowsHide: true,
138
- shell: false,
139
- });
140
- } catch (e) {
141
- this.죽음 = `띄우지 못했습니다: ${e.message}`;
142
- return false;
143
- }
144
- // 띄운 순간부터 명부에 든다. 악수(initialize)를 못 마쳐도 아이는 이미
145
- // 있으므로, 여기서 적으면 아이는 아무도 안 거두는 아이가 된다.
146
- 띄운것들.add(this);
147
-
148
- this.kid.on('error', (e) => this.끝냄(`오류: ${e.message}`));
149
- this.kid.on('exit', (code, sig) => this.끝냄(`끝났습니다 (코드 ${code ?? sig})`));
150
- this.kid.stdout.setEncoding('utf8');
151
- this.kid.stdout.on('data', (d) => this.받음(d));
152
- // 서버가 stderr 에 로그를 쏟는 일이 흔하다. 화면에 흘리면 대화가 뒤덮인다.
153
- // 마지막 것만 들고 있다가 죽었을 원인으로 보여 준다.
154
- this.kid.stderr.setEncoding('utf8');
155
- this.kid.stderr.on('data', (d) => { this.마지막말 = String(d).trim().slice(-400); });
156
-
157
- try {
158
- const r = await this.보내고기다리기('initialize', {
159
- protocolVersion: '2024-11-05',
160
- capabilities: { tools: {} },
161
- clientInfo: { name: 'deel', version: VERSION },
162
- }, timeout);
163
- this.정보 = r?.serverInfo ?? null;
164
- this.알림('notifications/initialized', {});
165
- } catch (e) {
166
- this.끝냄(`규격 인사에 실패했습니다: ${e.message}`);
167
- return false;
168
- }
169
-
170
- try {
171
- const r = await this.보내고기다리기('tools/list', {}, timeout);
172
- const = Array.isArray(r?.tools) ? r.tools : [];
173
- this.도구 = 다.slice(0, 도구최대);
174
- this.잘림 = Math.max(0, 다.length - this.도구.length);
175
- } catch (e) {
176
- this.끝냄(`도구 목록을 받았습니다: ${e.message}`);
177
- return false;
178
- }
179
- return true;
180
- }
181
-
182
- 받음(덩이) {
183
- this.찌꺼기 += 덩이;
184
- if (this.찌꺼기.length > 줄최대) {
185
- this.끝냄('한 통이 너무 큽니다 — 규격에 안 맞는 서버입니다');
186
- return;
187
- }
188
- let i = this.찌꺼기.indexOf('\n');
189
- while (i >= 0) {
190
- const = this.찌꺼기.slice(0, i).trim();
191
- this.찌꺼기 = this.찌꺼기.slice(i + 1);
192
- if (줄) this.한통();
193
- i = this.찌꺼기.indexOf('\n');
194
- }
195
- }
196
-
197
- 한통(줄) {
198
- let j;
199
- try { j = JSON.parse(줄); } catch { return; } // 규격 밖의 잡소리는 버린다
200
- if (j.id == null) return; // 알림은 아직 쓴다
201
- const 기다림 = this.기다리는것.get(j.id);
202
- if (!기다림) return;
203
- this.기다리는것.delete(j.id);
204
- clearTimeout(기다림.타이머);
205
- // 끝난 자리의 ESC 엿듣기는 떼어 낸다. 안 떼면 한 턴에 도구를 스무 번
206
- // 부르는 사이 신호 하나에 스무 개가 매달린다 노드가 넘으면
207
- // 「메모리가 새는 같다」 경고를 찍는데, 실제로 새는 것이 맞다.
208
- 기다림.끊기그만?.();
209
- if (j.error) 기다림.실패(new Error(j.error.message ?? '알 수 없는 오류'));
210
- else 기다림.성공(j.result);
211
- }
212
-
213
- /**
214
- * 한 통 보내고 답을 기다린다.
215
- *
216
- * signal 은 사람이 누른 ESC 다. 안 받으면 도구 하나 부르는 데 최대 60초
217
- * (부르기제한)를 기다리는데, 60초 동안 ESC 아무것도 한다 화면은
218
- * 「멈추는 중…」 인데 남의 프로세스의 답을 계속 기다리고 있는 상태다.
219
- * 그래서 시한과 같은 자리에서 같은 방식으로 푼다: 기다리는 표에서 빼고,
220
- * 끝났는지를 말로 남기고 끝낸다.
221
- */
222
- 보내고기다리기(method, params, timeout = 부르기제한, signal = null) {
223
- return new Promise((성공, 실패) => {
224
- if (!this.kid || this.kid.exitCode !== null) return 실패(new Error(this.죽음 ?? '연결이 없습니다'));
225
- // 이미 멈췄으면 보내지도 않는다. 보내 놓고 버리면 남의 서버는 일을 끝까지 한다.
226
- if (signal?.aborted) return 실패(new Error('중단했습니다'));
227
- const id = this.다음번호++;
228
- const 타이머 = setTimeout(() => {
229
- this.기다리는것.delete(id);
230
- 끊기그만();
231
- 실패(new Error(`${Math.round(timeout / 1000)}초 안에 답이 없습니다`));
232
- }, timeout);
233
- if (타이머.unref) 타이머.unref();
234
- /*
235
- * 기다리는 표에서 **반드시** 뺀다.
236
- *
237
- * 안 빼면 뒤늦게 온 답이 이미 끝난 약속을 또 푼다. 두 번째 풀기는
238
- * 조용히 무시되므로 오류는 나지만, 표에 죽은 자리가 남아서
239
- * 끝냄() 그것들을 다시 실패시킨다 아무도 듣는 실패다.
240
- */
241
- const 끊겼다 = () => {
242
- clearTimeout(타이머);
243
- this.기다리는것.delete(id);
244
- 실패(new Error('중단했습니다'));
245
- };
246
- signal?.addEventListener?.('abort', 끊겼다, { once: true });
247
- const 끊기그만 = () => signal?.removeEventListener?.('abort', 끊겼다);
248
- this.기다리는것.set(id, { 성공, 실패, 타이머, 끊기그만 });
249
- try {
250
- this.kid.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
251
- } catch (e) {
252
- clearTimeout(타이머);
253
- this.기다리는것.delete(id);
254
- 끊기그만();
255
- 실패(e);
256
- }
257
- });
258
- }
259
-
260
- 알림(method, params) {
261
- try { this.kid?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); } catch { /* 죽었으면 어차피 끝이다 */ }
262
- }
263
-
264
- async 부르기(도구이름, args, { timeout = 부르기제한, signal = null } = {}) {
265
- const r = await this.보내고기다리기('tools/call', { name: 도구이름, arguments: args ?? {} }, timeout, signal);
266
- // 규격상 결과는 content 배열이다. 글만 뽑아 모델에게 넘긴다.
267
- const 조각 = Array.isArray(r?.content) ? r.content : [];
268
- const = 조각
269
- .map((p) => (p?.type === 'text' ? p.text : p?.type ? `[${p.type}]` : ''))
270
- .filter(Boolean).join('\n');
271
- return { text: 글, isError: r?.isError === true };
272
- }
273
-
274
- 끝냄(왜) {
275
- if (this.죽음) return;
276
- this.죽음 = this.마지막말 ? `${왜} — ${this.마지막말}` : 왜;
277
- for (const [, 기다림] of this.기다리는것) {
278
- clearTimeout(기다림.타이머);
279
- 기다림.끊기그만?.();
280
- 기다림.실패(new Error(this.죽음));
281
- }
282
- this.기다리는것.clear();
283
- }
284
-
285
- 닫기() {
286
- this.끝냄('닫았습니다');
287
- 띄운것들.delete(this);
288
- try {
289
- this.kid?.stdin?.end();
290
- this.kid?.kill();
291
- // 자식이 살아 있으면 우리 프로그램이 안 끝난다.
292
- this.kid?.unref?.();
293
- } catch { /* 이미 죽었다 */ }
294
- }
295
- }
296
-
297
- /**
298
- * 우리 환경변수를 통째로 넘기지 않는다.
299
- *
300
- * DEEL_* 에는 게이트웨이 열쇠가 들어 있을 수 있고, 그 값이 남의 프로세스로
301
- * 넘어가면 어디로 가는지 우리가 없다. 프로그램이 도는 꼭 필요한
302
- * 것만 남긴다.
303
- */
304
- function 깨끗한환경() {
305
- const 남길것 = ['PATH', 'Path', 'PATHEXT', 'HOME', 'USERPROFILE', 'TEMP', 'TMP', 'SystemRoot', 'windir', 'COMSPEC', 'LANG', 'LC_ALL', 'APPDATA', 'LOCALAPPDATA', 'ProgramFiles', 'ProgramData', 'NODE_PATH'];
306
- const out = {};
307
- for (const k of 남길것) if (process.env[k] != null) out[k] = process.env[k];
308
- return out;
309
- }
310
-
311
- /**
312
- * 게이트웨이 열쇠 **하나만** 뺀 환경. Bash 와 Jobs 가 자식에게 넘길 것.
313
- *
314
- * 위 깨끗한환경 은 남의 프로그램(MCP 서버)에 주는 것이라 통째로 씻는다.
315
- * 여기는 다르다 Bash 도는 것은 **사용자 프로젝트**다. PATH·NODE_ENV·
316
- * DEEL_HOME·사내 프록시 설정이 있어야 하고(사용자는 프로그램의 검사
317
- * 자체를 Bash 돌린다), 하나라도 빠지면 「내 터미널에서는 되는데」 가 된다.
318
- *
319
- * 그래서 딱 하나만 뺀다. 안 빼면 `env` 한 줄로 열쇠가 화면에 찍히고, 그 화면이
320
- * 대화에 실려 게이트웨이로 나가고 `.deel/sessions/*.jsonl` 디스크에도 남는다
321
- * 열쇠를 열쇠의 주인에게 보내는 셈이다(guard.js 막는 것과 같은 길).
322
- * 자식이 무엇을 하든 값이 필요할 일은 없다. 게이트웨이로 나가는 것은 우리다.
323
- */
324
- export function 열쇠뺀환경(env = process.env) {
325
- const out = { ...env };
326
- delete out.DEEL_API_KEY;
327
- return out;
328
- }
329
-
330
- /** 우리 도구 이름과 부딪히게 앞에 서버 이름을 붙인다. Claude Code 와 같은 꼴이다. */
331
- export const 도구이름 = (서버, 도구) => `mcp__${서버}__${도구}`;
332
-
333
- /** 붙인 이름에서 서버와 도구를 도로 뗀다. */
334
- export function 이름풀기(전체) {
335
- const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(String(전체 ?? ''));
336
- return m ? { 서버: m[1], 도구: m[2] } : null;
337
- }
338
-
339
- /**
340
- * 설정에 적힌 서버를 전부 띄운다.
341
- *
342
- * 하나가떠도 나머지는 쓴다 서버 하나 때문에 프로그램이 뜨면 안 된다.
343
- * 것은 **안 떴다고 말한다.** 조용히 빠지면 "왜 그 도구가 없지" 를
344
- * 영영 알 수 없다.
345
- */
346
- export async function 다붙이기(root, { offline = false, timeout = 붙기제한, audit = null } = {}) {
347
- const 설정 = 설정읽기(root);
348
- if (설정.오류) return { 서버들: [], 못한것: [{ 이름: '(설정)', 왜: 설정.오류 }], 설정 };
349
- if (!설정.서버들.length) return { 서버들: [], 못한것: [], 설정 };
350
-
351
- // 자물쇠가 걸려 있으면 아예 안 띄운다. 자식 프로세스가 어디로 나가는지
352
- // 우리는 막는다 막을 수 없는 것을 막았다고 말하지 않는다.
353
- if (offline) {
354
- return {
355
- 서버들: [],
356
- 못한것: 설정.서버들.map((s) => ({ 이름: s.이름, 왜: '오프라인 잠금 중에는 안 띄웁니다' })),
357
- 설정,
358
- 잠김: true,
359
- };
360
- }
361
-
362
- const 붙은것 = [];
363
- const 못한것 = [];
364
- await Promise.all(설정.서버들.map(async (s) => {
365
- const 서버 = new MCP서버(s);
366
- const ok = await 서버.붙기({ timeout });
367
- if (ok) {
368
- 붙은것.push(서버);
369
- audit?.write?.('mcp', { 이름: s.이름, command: s.command, 도구: 서버.도구.length });
370
- } else {
371
- 못한것.push({ 이름: s.이름, 왜: 서버.죽음 ?? '알 수 없는 이유' });
372
- 서버.닫기();
373
- }
374
- }));
375
- 붙은것.sort((a, b) => a.이름.localeCompare(b.이름));
376
- return { 서버들: 붙은것, 못한것, 설정 };
377
- }
378
-
379
- /** 모델에게 넘길 도구 정의로 바꾼다. */
380
- export function 도구정의(서버들) {
381
- const out = [];
382
- for (const s of 서버들) {
383
- for (const t of s.도구) {
384
- out.push({
385
- type: 'function',
386
- function: {
387
- name: 도구이름(s.이름, t.name),
388
- description: `[${s.이름}] ${t.description ?? t.name}`,
389
- parameters: t.inputSchema ?? { type: 'object', properties: {} },
390
- },
391
- });
392
- }
393
- }
394
- return out;
395
- }
1
+ /**
2
+ * MCP(Model Context Protocol) 서버 붙이기 — stdio 규격.
3
+ *
4
+ * 무엇인가:
5
+ * 도구를 **코드를 안 고치고** 밖에서 붙이는 규격이다. 사내 위키 검색기,
6
+ * 사내 이슈 트래커, DB 조회기 같은 것을 각 팀이 MCP 서버로 만들어 두면
7
+ * deel 은 그걸 그대로 도구로 쓴다. 우리가 매번 도구를 새로 만들지 않아도 된다.
8
+ *
9
+ * 왜 의존성 없이 되나:
10
+ * stdio 규격은 자식 프로세스의 stdin/stdout 에 **줄 단위 JSON-RPC 2.0** 을
11
+ * 주고받는 것이 전부다. child_process 와 JSON 이면 된다. SDK 가 필요 없다.
12
+ *
13
+ * ── 안전에 대해 ────────────────────────────────────────────────────────
14
+ *
15
+ * MCP 서버는 **남의 프로그램**이다. 이 프로젝트가 존재하는 이유가 '미승인 SW
16
+ * 반입 금지' 인데, MCP 를 아무렇게나 켜면 그 선을 우리 손으로 무너뜨리는 셈이다.
17
+ * 그래서:
18
+ *
19
+ * 1) **기본은 꺼져 있다.** .deel/mcp.json 에 사람이 직접 적어야만 뜬다.
20
+ * 2) **--offline 이면 아예 안 띄운다.** 자식 프로세스가 어디로 나가는지
21
+ * 우리는 못 막는다. 막을 수 없는 것을 막았다고 말하지 않는다.
22
+ * 3) **감사기록에 남긴다.** 무엇을 띄웠고 무엇을 불렀는지.
23
+ * 4) **작업 범위 밖이다.** MCP 서버는 우리 scope 를 안 지킨다 —
24
+ * 제 마음대로 파일을 읽고 쓸 수 있다. /mcp 화면에서 그렇다고 말한다.
25
+ */
26
+ import { spawn } from 'node:child_process';
27
+ import { existsSync, readFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { VERSION } from '../version.js';
30
+ import { 열쇠환경인가 } from '../config.js';
31
+
32
+ // 붙는 이만큼 넘게 걸리면 포기한다. 시작이 느려지면 안 쓰게 된다.
33
+ export const 붙기제한 = 8000;
34
+ // 도구 하나 부르고 이만큼 기다린다.
35
+ export const 부르기제한 = 60000;
36
+ // 서버에서 받을 도구 수. 스키마가 통째로 매 요청에 실리므로 무한정 받으면
37
+ // 컨텍스트가 조용히 줄어든다. 넘으면 **넘었다고 말하고** 자른다.
38
+ export const 도구최대 = 24;
39
+ // 줄(JSON 통)의 최대 크기. 미친 서버가 stdout 을 쏟아부어도 안 죽게.
40
+ const 줄최대 = 4 * 1024 * 1024;
41
+
42
+ export const 설정자리 = (root) => join(root, '.deel', 'mcp.json');
43
+
44
+ /**
45
+ * 설정을 읽는다. Claude Code `mcpServers` 모양을 그대로 받는다 —
46
+ * 이미 쓰던 설정을 복사해 붙일 수 있어야 한다.
47
+ */
48
+ export function 설정읽기(root) {
49
+ const p = 설정자리(root);
50
+ if (!existsSync(p)) return { 서버들: [], 자리: p, 있음: false };
51
+ let j;
52
+ try { j = JSON.parse(readFileSync(p, 'utf8')); } catch (e) {
53
+ return { 서버들: [], 자리: p, 있음: true, 오류: `mcp.json 을 못 읽었습니다: ${e.message}` };
54
+ }
55
+ const = j.mcpServers ?? j.servers ?? {};
56
+ const 서버들 = [];
57
+ for (const [이름, v] of Object.entries(표)) {
58
+ if (v?.disabled === true) continue;
59
+ // stdio 받는다. http/sse 규격은 바깥으로 나가는 것이라 자물쇠와 부딪힌다.
60
+ if (v?.type && v.type !== 'stdio') continue;
61
+ if (!v?.command) continue;
62
+ 서버들.push({
63
+ 이름,
64
+ command: String(v.command),
65
+ args: Array.isArray(v.args) ? v.args.map(String) : [],
66
+ env: v.env && typeof v.env === 'object' ? v.env : null,
67
+ cwd: v.cwd ? String(v.cwd) : root,
68
+ });
69
+ }
70
+ return { 서버들, 자리: p, 있음: true };
71
+ }
72
+
73
+ /*
74
+ * ── 지금 띄워 둔 서버들 ─────────────────────────────────────────────────
75
+ *
76
+ * 여기 명부가 있나. MCP 서버는 붙인 쪽(repl·ACP)이 들고 있을 뿐이라,
77
+ * 프로그램이 어느 길로든 끝나 버리면 **아무도 닫는다.** 그러면 사람
78
+ * 컴퓨터에 서버 프로세스가 하나씩 쌓인다 — 작업 관리자를 열기 전에는
79
+ * 모르는 종류의 탈이다.
80
+ *
81
+ * 일감(tools/jobs.js)과 언어 서버(lsp/client.js)에는 이미 그물이 있는데
82
+ * 여기만 없었다. 같은 자리, 같은 규칙으로 둔다.
83
+ */
84
+ const 띄운것들 = new Set();
85
+
86
+ /** 검사와 진단이 본다. 지금 살아 있는 서버 수. */
87
+ export function 살아있는수() { return 띄운것들.size; }
88
+
89
+ /**
90
+ * 닫는다. 프로그램이 끝날 때와 검사 뒤에 부른다.
91
+ * @returns {number} 닫은 개수
92
+ */
93
+ export function 모두닫기() {
94
+ const 것들 = [...띄운것들];
95
+ 띄운것들.clear();
96
+ let n = 0;
97
+ for (const s of 것들) { try { s.닫기(); n++; } catch { /* 끝나는 중이라 할 수 있는 게 없다 */ } }
98
+ return n;
99
+ }
100
+
101
+ /*
102
+ * 어떤 길로 끝나든 남기지 않는다. 붙인 쪽이 이미 닫았어도 무해하다.
103
+ *
104
+ * 이름 있는 함수를 그대로 건다 이름 없는 화살표로 걸면 그물이 걸려 있는지
105
+ * 검사가 밖에서 확인할 길이 없다. 걷어내도 아무도 모르는 그물은 없는 것과 같다.
106
+ */
107
+ process.once('exit', 모두닫기);
108
+
109
+ /**
110
+ * 서버 하나와의 연결.
111
+ *
112
+ * 규격은 JSON-RPC 2.0 이다. 줄 하나에 통 하나 — 그래서 줄 단위로 자르면 된다.
113
+ */
114
+ export class MCP서버 {
115
+ constructor(설정) {
116
+ this.이름 = 설정.이름;
117
+ this.설정 = 설정;
118
+ this.kid = null;
119
+ this.다음번호 = 1;
120
+ this.기다리는것 = new Map();
121
+ this.찌꺼기 = '';
122
+ this.도구 = [];
123
+ this.정보 = null;
124
+ this.죽음 = null; // 죽었나 (사람에게 보여 줄 말)
125
+ this.잘림 = 0; // 도구최대 를 넘어 자른 개수
126
+ }
127
+
128
+ 살아있나() { return !!this.kid && this.kid.exitCode === null && !this.죽음; }
129
+
130
+ async 붙기({ timeout = 붙기제한 } = {}) {
131
+ try {
132
+ this.kid = spawn(this.설정.command, this.설정.args, {
133
+ cwd: this.설정.cwd,
134
+ // 설정에 적힌 env 얹는다. 우리 환경변수를 통째로 넘기면
135
+ // 게이트웨이 열쇠(DEEL_*)까지 남의 프로세스로 넘어간다.
136
+ env: { ...깨끗한환경(), ...(this.설정.env ?? {}) },
137
+ stdio: ['pipe', 'pipe', 'pipe'],
138
+ windowsHide: true,
139
+ shell: false,
140
+ });
141
+ } catch (e) {
142
+ this.죽음 = `띄우지 못했습니다: ${e.message}`;
143
+ return false;
144
+ }
145
+ // 띄운 순간부터 명부에 든다. 악수(initialize)를 마쳐도 아이는 이미
146
+ // 떠 있으므로, 여기서 안 적으면 그 아이는 아무도 안 거두는 아이가 된다.
147
+ 띄운것들.add(this);
148
+
149
+ this.kid.on('error', (e) => this.끝냄(`오류: ${e.message}`));
150
+ this.kid.on('exit', (code, sig) => this.끝냄(`끝났습니다 (코드 ${code ?? sig})`));
151
+ this.kid.stdout.setEncoding('utf8');
152
+ this.kid.stdout.on('data', (d) => this.받음(d));
153
+ // 서버가 stderr 로그를 쏟는 일이 흔하다. 화면에 흘리면 대화가 뒤덮인다.
154
+ // 마지막 것만 들고 있다가 죽었을 때 원인으로 보여 준다.
155
+ this.kid.stderr.setEncoding('utf8');
156
+ this.kid.stderr.on('data', (d) => { this.마지막말 = String(d).trim().slice(-400); });
157
+
158
+ try {
159
+ const r = await this.보내고기다리기('initialize', {
160
+ protocolVersion: '2024-11-05',
161
+ capabilities: { tools: {} },
162
+ clientInfo: { name: 'deel', version: VERSION },
163
+ }, timeout);
164
+ this.정보 = r?.serverInfo ?? null;
165
+ this.알림('notifications/initialized', {});
166
+ } catch (e) {
167
+ this.끝냄(`규격 인사에 실패했습니다: ${e.message}`);
168
+ return false;
169
+ }
170
+
171
+ try {
172
+ const r = await this.보내고기다리기('tools/list', {}, timeout);
173
+ const = Array.isArray(r?.tools) ? r.tools : [];
174
+ this.도구 = 다.slice(0, 도구최대);
175
+ this.잘림 = Math.max(0, 다.length - this.도구.length);
176
+ } catch (e) {
177
+ this.끝냄(`도구 목록을 못 받았습니다: ${e.message}`);
178
+ return false;
179
+ }
180
+ return true;
181
+ }
182
+
183
+ 받음(덩이) {
184
+ this.찌꺼기 += 덩이;
185
+ if (this.찌꺼기.length > 줄최대) {
186
+ this.끝냄('한 통이 너무 큽니다 — 규격에 안 맞는 서버입니다');
187
+ return;
188
+ }
189
+ let i = this.찌꺼기.indexOf('\n');
190
+ while (i >= 0) {
191
+ const = this.찌꺼기.slice(0, i).trim();
192
+ this.찌꺼기 = this.찌꺼기.slice(i + 1);
193
+ if (줄) this.한통();
194
+ i = this.찌꺼기.indexOf('\n');
195
+ }
196
+ }
197
+
198
+ 한통(줄) {
199
+ let j;
200
+ try { j = JSON.parse(줄); } catch { return; } // 규격 밖의 잡소리는 버린다
201
+ if (j.id == null) return; // 알림은 아직 안 쓴다
202
+ const 기다림 = this.기다리는것.get(j.id);
203
+ if (!기다림) return;
204
+ this.기다리는것.delete(j.id);
205
+ clearTimeout(기다림.타이머);
206
+ // 끝난 자리의 ESC 엿듣기는 떼어 낸다. 떼면 턴에 도구를 스무 번
207
+ // 부르는 사이 신호 하나에 스무 개가 매달린다 노드가 개 넘으면
208
+ // 「메모리가 새는 것 같다」 고 경고를 찍는데, 실제로 새는 것이 맞다.
209
+ 기다림.끊기그만?.();
210
+ if (j.error) 기다림.실패(new Error(j.error.message ?? '알 수 없는 오류'));
211
+ else 기다림.성공(j.result);
212
+ }
213
+
214
+ /**
215
+ * 한 통 보내고 답을 기다린다.
216
+ *
217
+ * signal 사람이 누른 ESC 다.받으면 도구 하나 부르는 데 최대 60초
218
+ * (부르기제한)를 기다리는데, 60초 동안 ESC 아무것도 한다 — 화면은
219
+ * 「멈추는 중…」 인데 남의 프로세스의 답을 계속 기다리고 있는 상태다.
220
+ * 그래서 시한과 같은 자리에서 같은 방식으로 푼다: 기다리는 표에서 빼고,
221
+ * 왜 끝났는지를 말로 남기고 끝낸다.
222
+ */
223
+ 보내고기다리기(method, params, timeout = 부르기제한, signal = null) {
224
+ return new Promise((성공, 실패) => {
225
+ if (!this.kid || this.kid.exitCode !== null) return 실패(new Error(this.죽음 ?? '연결이 없습니다'));
226
+ // 이미 멈췄으면 보내지도 않는다. 보내 놓고 버리면 남의 서버는 그 일을 끝까지 한다.
227
+ if (signal?.aborted) return 실패(new Error('중단했습니다'));
228
+ const id = this.다음번호++;
229
+ const 타이머 = setTimeout(() => {
230
+ this.기다리는것.delete(id);
231
+ 끊기그만();
232
+ 실패(new Error(`${Math.round(timeout / 1000)}초 안에 답이 없습니다`));
233
+ }, timeout);
234
+ if (타이머.unref) 타이머.unref();
235
+ /*
236
+ * 기다리는 표에서 **반드시** 뺀다.
237
+ *
238
+ * 빼면 뒤늦게 답이 이미 끝난 약속을 또 푼다. 두 번째 풀기는
239
+ * 조용히 무시되므로 오류는 나지만, 표에 죽은 자리가 남아서
240
+ * 끝냄() 이 그것들을 다시 실패시킨다 — 아무도 안 듣는 실패다.
241
+ */
242
+ const 끊겼다 = () => {
243
+ clearTimeout(타이머);
244
+ this.기다리는것.delete(id);
245
+ 실패(new Error('중단했습니다'));
246
+ };
247
+ signal?.addEventListener?.('abort', 끊겼다, { once: true });
248
+ const 끊기그만 = () => signal?.removeEventListener?.('abort', 끊겼다);
249
+ this.기다리는것.set(id, { 성공, 실패, 타이머, 끊기그만 });
250
+ try {
251
+ this.kid.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
252
+ } catch (e) {
253
+ clearTimeout(타이머);
254
+ this.기다리는것.delete(id);
255
+ 끊기그만();
256
+ 실패(e);
257
+ }
258
+ });
259
+ }
260
+
261
+ 알림(method, params) {
262
+ try { this.kid?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); } catch { /* 죽었으면 어차피 끝이다 */ }
263
+ }
264
+
265
+ async 부르기(도구이름, args, { timeout = 부르기제한, signal = null } = {}) {
266
+ const r = await this.보내고기다리기('tools/call', { name: 도구이름, arguments: args ?? {} }, timeout, signal);
267
+ // 규격상 결과는 content 배열이다. 글만 뽑아 모델에게 넘긴다.
268
+ const 조각 = Array.isArray(r?.content) ? r.content : [];
269
+ const = 조각
270
+ .map((p) => (p?.type === 'text' ? p.text : p?.type ? `[${p.type}]` : ''))
271
+ .filter(Boolean).join('\n');
272
+ return { text: 글, isError: r?.isError === true };
273
+ }
274
+
275
+ 끝냄() {
276
+ if (this.죽음) return;
277
+ this.죽음 = this.마지막말 ? `${왜} — ${this.마지막말}` : 왜;
278
+ for (const [, 기다림] of this.기다리는것) {
279
+ clearTimeout(기다림.타이머);
280
+ 기다림.끊기그만?.();
281
+ 기다림.실패(new Error(this.죽음));
282
+ }
283
+ this.기다리는것.clear();
284
+ }
285
+
286
+ 닫기() {
287
+ this.끝냄('닫았습니다');
288
+ 띄운것들.delete(this);
289
+ try {
290
+ this.kid?.stdin?.end();
291
+ this.kid?.kill();
292
+ // 자식이 살아 있으면 우리 프로그램이 안 끝난다.
293
+ this.kid?.unref?.();
294
+ } catch { /* 이미 죽었다 */ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * 우리 환경변수를 통째로 넘기지 않는다.
300
+ *
301
+ * DEEL_* 에는 게이트웨이 열쇠가 들어 있을 있고, 값이 남의 프로세스로
302
+ * 넘어가면 어디로 가는지 우리가 알 수 없다. 프로그램이 도는 데 꼭 필요한
303
+ * 것만 남긴다.
304
+ */
305
+ export function 깨끗한환경() {
306
+ const 남길것 = ['PATH', 'Path', 'PATHEXT', 'HOME', 'USERPROFILE', 'TEMP', 'TMP', 'SystemRoot', 'windir', 'COMSPEC', 'LANG', 'LC_ALL', 'APPDATA', 'LOCALAPPDATA', 'ProgramFiles', 'ProgramData', 'NODE_PATH'];
307
+ const out = {};
308
+ for (const k of 남길것) if (process.env[k] != null) out[k] = process.env[k];
309
+ return out;
310
+ }
311
+
312
+ /**
313
+ * 게이트웨이 열쇠**만** 뺀 환경. Bash 와 Jobs 가 자식에게 넘길 것.
314
+ *
315
+ * 깨끗한환경 남의 프로그램(MCP 서버)에 주는 것이라 통째로 씻는다.
316
+ * 여기는 다르다 Bash 도는 것은 **사용자 제 프로젝트**다. PATH·NODE_ENV·
317
+ * DEEL_HOME·사내 프록시 설정이 있어야 하고(사용자는 프로그램의 검사
318
+ * 자체를 Bash 로 돌린다), 하나라도 빠지면 「내 터미널에서는 되는데」 가 된다.
319
+ *
320
+ * 그래서 하나만 뺀다. 안 빼면 `env` 줄로 열쇠가 화면에 찍히고, 그 화면이
321
+ * 대화에 실려 게이트웨이로 나가고 `.deel/sessions/*.jsonl` 디스크에도 남는다
322
+ * 열쇠를 열쇠의 주인에게 보내는 셈이다(guard.js 막는 것과 같은 길).
323
+ * 자식이 무엇을 하든 이 값이 필요할 일은 없다. 게이트웨이로 나가는 것은 우리다.
324
+ */
325
+ export function 열쇠뺀환경(env = process.env) {
326
+ const out = { ...env };
327
+ /*
328
+ * 이름을 못 박지 않는다.
329
+ *
330
+ * 여기가 `delete out.DEEL_API_KEY` 줄이었다. 열쇠 이름은 **둘**인데
331
+ * (config.js resolveKey), 그중 하나만 지운 것이다. 프로필 열쇠
332
+ * (`DEEL_KEY_PROD`)를 쓰는 사람은 `env` 한 줄로 그대로 샜다 — 그리고
333
+ * 방법을 우리 심사 명세가 권장으로 적어 뒀다.
334
+ *
335
+ * 무엇이 열쇠인지는 **읽는 자에게 묻는다**(열쇠환경인가). 이름이 늘면
336
+ * 거기만 고친다. 여기서 다시 적으면 반드시 한쪽이 낡는다.
337
+ */
338
+ for (const k of Object.keys(out)) if (열쇠환경인가(k)) delete out[k];
339
+ return out;
340
+ }
341
+
342
+ /** 우리 도구 이름과 부딪히게 앞에 서버 이름을 붙인다. Claude Code 같은 꼴이다. */
343
+ export const 도구이름 = (서버, 도구) => `mcp__${서버}__${도구}`;
344
+
345
+ /** 붙인 이름에서 서버와 도구를 도로 뗀다. */
346
+ export function 이름풀기(전체) {
347
+ const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(String(전체 ?? ''));
348
+ return m ? { 서버: m[1], 도구: m[2] } : null;
349
+ }
350
+
351
+ /**
352
+ * 설정에 적힌 서버를 전부 띄운다.
353
+ *
354
+ * 하나가 안 떠도 나머지는 쓴다 — 서버 하나 때문에 프로그램이 못 뜨면 안 된다.
355
+ * 안 뜬 것은 **안 떴다고 말한다.** 조용히 빠지면 "왜 그 도구가 없지" 를
356
+ * 영영 없다.
357
+ */
358
+ export async function 다붙이기(root, { offline = false, timeout = 붙기제한, audit = null } = {}) {
359
+ const 설정 = 설정읽기(root);
360
+ if (설정.오류) return { 서버들: [], 못한것: [{ 이름: '(설정)', 왜: 설정.오류 }], 설정 };
361
+ if (!설정.서버들.length) return { 서버들: [], 못한것: [], 설정 };
362
+
363
+ // 자물쇠가 걸려 있으면 아예 안 띄운다. 자식 프로세스가 어디로 나가는지
364
+ // 우리는 막는다 — 막을 수 없는 것을 막았다고 말하지 않는다.
365
+ if (offline) {
366
+ return {
367
+ 서버들: [],
368
+ 못한것: 설정.서버들.map((s) => ({ 이름: s.이름, 왜: '오프라인 잠금 중에는 안 띄웁니다' })),
369
+ 설정,
370
+ 잠김: true,
371
+ };
372
+ }
373
+
374
+ const 붙은것 = [];
375
+ const 못한것 = [];
376
+ await Promise.all(설정.서버들.map(async (s) => {
377
+ const 서버 = new MCP서버(s);
378
+ const ok = await 서버.붙기({ timeout });
379
+ if (ok) {
380
+ 붙은것.push(서버);
381
+ audit?.write?.('mcp', { 이름: s.이름, command: s.command, 도구: 서버.도구.length });
382
+ } else {
383
+ 못한것.push({ 이름: s.이름, 왜: 서버.죽음 ?? '알 수 없는 이유' });
384
+ 서버.닫기();
385
+ }
386
+ }));
387
+ 붙은것.sort((a, b) => a.이름.localeCompare(b.이름));
388
+ return { 서버들: 붙은것, 못한것, 설정 };
389
+ }
390
+
391
+ /** 모델에게 넘길 도구 정의로 바꾼다. */
392
+ export function 도구정의(서버들) {
393
+ const out = [];
394
+ for (const s of 서버들) {
395
+ for (const t of s.도구) {
396
+ out.push({
397
+ type: 'function',
398
+ function: {
399
+ name: 도구이름(s.이름, t.name),
400
+ description: `[${s.이름}] ${t.description ?? t.name}`,
401
+ parameters: t.inputSchema ?? { type: 'object', properties: {} },
402
+ },
403
+ });
404
+ }
405
+ }
406
+ return out;
407
+ }