deel-local-cli 0.9.0 → 1.0.1

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.
@@ -0,0 +1,304 @@
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
+ * 규격은 JSON-RPC 2.0 이다. 줄 하나에 통 하나 — 그래서 줄 단위로 자르면 된다.
76
+ */
77
+ export class MCP서버 {
78
+ constructor(설정) {
79
+ this.이름 = 설정.이름;
80
+ this.설정 = 설정;
81
+ this.kid = null;
82
+ this.다음번호 = 1;
83
+ this.기다리는것 = new Map();
84
+ this.찌꺼기 = '';
85
+ this.도구 = [];
86
+ this.정보 = null;
87
+ this.죽음 = null; // 왜 죽었나 (사람에게 보여 줄 말)
88
+ this.잘림 = 0; // 도구최대 를 넘어 자른 개수
89
+ }
90
+
91
+ 살아있나() { return !!this.kid && this.kid.exitCode === null && !this.죽음; }
92
+
93
+ async 붙기({ timeout = 붙기제한 } = {}) {
94
+ try {
95
+ this.kid = spawn(this.설정.command, this.설정.args, {
96
+ cwd: this.설정.cwd,
97
+ // 설정에 적힌 env 만 얹는다. 우리 환경변수를 통째로 넘기면
98
+ // 게이트웨이 열쇠(DEEL_*)까지 남의 프로세스로 넘어간다.
99
+ env: { ...깨끗한환경(), ...(this.설정.env ?? {}) },
100
+ stdio: ['pipe', 'pipe', 'pipe'],
101
+ windowsHide: true,
102
+ shell: false,
103
+ });
104
+ } catch (e) {
105
+ this.죽음 = `띄우지 못했습니다: ${e.message}`;
106
+ return false;
107
+ }
108
+
109
+ this.kid.on('error', (e) => this.끝냄(`오류: ${e.message}`));
110
+ this.kid.on('exit', (code, sig) => this.끝냄(`끝났습니다 (코드 ${code ?? sig})`));
111
+ this.kid.stdout.setEncoding('utf8');
112
+ this.kid.stdout.on('data', (d) => this.받음(d));
113
+ // 서버가 stderr 에 로그를 쏟는 일이 흔하다. 화면에 흘리면 대화가 뒤덮인다.
114
+ // 마지막 것만 들고 있다가 죽었을 때 원인으로 보여 준다.
115
+ this.kid.stderr.setEncoding('utf8');
116
+ this.kid.stderr.on('data', (d) => { this.마지막말 = String(d).trim().slice(-400); });
117
+
118
+ try {
119
+ const r = await this.보내고기다리기('initialize', {
120
+ protocolVersion: '2024-11-05',
121
+ capabilities: { tools: {} },
122
+ clientInfo: { name: 'deel', version: VERSION },
123
+ }, timeout);
124
+ this.정보 = r?.serverInfo ?? null;
125
+ this.알림('notifications/initialized', {});
126
+ } catch (e) {
127
+ this.끝냄(`규격 인사에 실패했습니다: ${e.message}`);
128
+ return false;
129
+ }
130
+
131
+ try {
132
+ const r = await this.보내고기다리기('tools/list', {}, timeout);
133
+ const 다 = Array.isArray(r?.tools) ? r.tools : [];
134
+ this.도구 = 다.slice(0, 도구최대);
135
+ this.잘림 = Math.max(0, 다.length - this.도구.length);
136
+ } catch (e) {
137
+ this.끝냄(`도구 목록을 못 받았습니다: ${e.message}`);
138
+ return false;
139
+ }
140
+ return true;
141
+ }
142
+
143
+ 받음(덩이) {
144
+ this.찌꺼기 += 덩이;
145
+ if (this.찌꺼기.length > 줄최대) {
146
+ this.끝냄('한 통이 너무 큽니다 — 규격에 안 맞는 서버입니다');
147
+ return;
148
+ }
149
+ let i = this.찌꺼기.indexOf('\n');
150
+ while (i >= 0) {
151
+ const 줄 = this.찌꺼기.slice(0, i).trim();
152
+ this.찌꺼기 = this.찌꺼기.slice(i + 1);
153
+ if (줄) this.한통(줄);
154
+ i = this.찌꺼기.indexOf('\n');
155
+ }
156
+ }
157
+
158
+ 한통(줄) {
159
+ let j;
160
+ try { j = JSON.parse(줄); } catch { return; } // 규격 밖의 잡소리는 버린다
161
+ if (j.id == null) return; // 알림은 아직 안 쓴다
162
+ const 기다림 = this.기다리는것.get(j.id);
163
+ if (!기다림) return;
164
+ this.기다리는것.delete(j.id);
165
+ clearTimeout(기다림.타이머);
166
+ if (j.error) 기다림.실패(new Error(j.error.message ?? '알 수 없는 오류'));
167
+ else 기다림.성공(j.result);
168
+ }
169
+
170
+ 보내고기다리기(method, params, timeout = 부르기제한) {
171
+ return new Promise((성공, 실패) => {
172
+ if (!this.kid || this.kid.exitCode !== null) return 실패(new Error(this.죽음 ?? '연결이 없습니다'));
173
+ const id = this.다음번호++;
174
+ const 타이머 = setTimeout(() => {
175
+ this.기다리는것.delete(id);
176
+ 실패(new Error(`${Math.round(timeout / 1000)}초 안에 답이 없습니다`));
177
+ }, timeout);
178
+ if (타이머.unref) 타이머.unref();
179
+ this.기다리는것.set(id, { 성공, 실패, 타이머 });
180
+ try {
181
+ this.kid.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
182
+ } catch (e) {
183
+ clearTimeout(타이머);
184
+ this.기다리는것.delete(id);
185
+ 실패(e);
186
+ }
187
+ });
188
+ }
189
+
190
+ 알림(method, params) {
191
+ try { this.kid?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); } catch { /* 죽었으면 어차피 끝이다 */ }
192
+ }
193
+
194
+ async 부르기(도구이름, args, { timeout = 부르기제한 } = {}) {
195
+ const r = await this.보내고기다리기('tools/call', { name: 도구이름, arguments: args ?? {} }, timeout);
196
+ // 규격상 결과는 content 배열이다. 글만 뽑아 모델에게 넘긴다.
197
+ const 조각 = Array.isArray(r?.content) ? r.content : [];
198
+ const 글 = 조각
199
+ .map((p) => (p?.type === 'text' ? p.text : p?.type ? `[${p.type}]` : ''))
200
+ .filter(Boolean).join('\n');
201
+ return { text: 글, isError: r?.isError === true };
202
+ }
203
+
204
+ 끝냄(왜) {
205
+ if (this.죽음) return;
206
+ this.죽음 = this.마지막말 ? `${왜} — ${this.마지막말}` : 왜;
207
+ for (const [, 기다림] of this.기다리는것) {
208
+ clearTimeout(기다림.타이머);
209
+ 기다림.실패(new Error(this.죽음));
210
+ }
211
+ this.기다리는것.clear();
212
+ }
213
+
214
+ 닫기() {
215
+ this.끝냄('닫았습니다');
216
+ try {
217
+ this.kid?.stdin?.end();
218
+ this.kid?.kill();
219
+ // 자식이 살아 있으면 우리 프로그램이 안 끝난다.
220
+ this.kid?.unref?.();
221
+ } catch { /* 이미 죽었다 */ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * 우리 환경변수를 통째로 넘기지 않는다.
227
+ *
228
+ * DEEL_* 에는 게이트웨이 열쇠가 들어 있을 수 있고, 그 값이 남의 프로세스로
229
+ * 넘어가면 어디로 가는지 우리가 알 수 없다. 프로그램이 도는 데 꼭 필요한
230
+ * 것만 남긴다.
231
+ */
232
+ function 깨끗한환경() {
233
+ const 남길것 = ['PATH', 'Path', 'PATHEXT', 'HOME', 'USERPROFILE', 'TEMP', 'TMP', 'SystemRoot', 'windir', 'COMSPEC', 'LANG', 'LC_ALL', 'APPDATA', 'LOCALAPPDATA', 'ProgramFiles', 'ProgramData', 'NODE_PATH'];
234
+ const out = {};
235
+ for (const k of 남길것) if (process.env[k] != null) out[k] = process.env[k];
236
+ return out;
237
+ }
238
+
239
+ /** 우리 도구 이름과 안 부딪히게 앞에 서버 이름을 붙인다. Claude Code 와 같은 꼴이다. */
240
+ export const 도구이름 = (서버, 도구) => `mcp__${서버}__${도구}`;
241
+
242
+ /** 붙인 이름에서 서버와 도구를 도로 뗀다. */
243
+ export function 이름풀기(전체) {
244
+ const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(String(전체 ?? ''));
245
+ return m ? { 서버: m[1], 도구: m[2] } : null;
246
+ }
247
+
248
+ /**
249
+ * 설정에 적힌 서버를 전부 띄운다.
250
+ *
251
+ * 하나가 안 떠도 나머지는 쓴다 — 서버 하나 때문에 프로그램이 못 뜨면 안 된다.
252
+ * 안 뜬 것은 **안 떴다고 말한다.** 조용히 빠지면 "왜 그 도구가 없지" 를
253
+ * 영영 알 수 없다.
254
+ */
255
+ export async function 다붙이기(root, { offline = false, timeout = 붙기제한, audit = null } = {}) {
256
+ const 설정 = 설정읽기(root);
257
+ if (설정.오류) return { 서버들: [], 못한것: [{ 이름: '(설정)', 왜: 설정.오류 }], 설정 };
258
+ if (!설정.서버들.length) return { 서버들: [], 못한것: [], 설정 };
259
+
260
+ // 자물쇠가 걸려 있으면 아예 안 띄운다. 자식 프로세스가 어디로 나가는지
261
+ // 우리는 못 막는다 — 막을 수 없는 것을 막았다고 말하지 않는다.
262
+ if (offline) {
263
+ return {
264
+ 서버들: [],
265
+ 못한것: 설정.서버들.map((s) => ({ 이름: s.이름, 왜: '오프라인 잠금 중에는 안 띄웁니다' })),
266
+ 설정,
267
+ 잠김: true,
268
+ };
269
+ }
270
+
271
+ const 붙은것 = [];
272
+ const 못한것 = [];
273
+ await Promise.all(설정.서버들.map(async (s) => {
274
+ const 서버 = new MCP서버(s);
275
+ const ok = await 서버.붙기({ timeout });
276
+ if (ok) {
277
+ 붙은것.push(서버);
278
+ audit?.note?.('mcp', { 이름: s.이름, command: s.command, 도구: 서버.도구.length });
279
+ } else {
280
+ 못한것.push({ 이름: s.이름, 왜: 서버.죽음 ?? '알 수 없는 이유' });
281
+ 서버.닫기();
282
+ }
283
+ }));
284
+ 붙은것.sort((a, b) => a.이름.localeCompare(b.이름));
285
+ return { 서버들: 붙은것, 못한것, 설정 };
286
+ }
287
+
288
+ /** 모델에게 넘길 도구 정의로 바꾼다. */
289
+ export function 도구정의(서버들) {
290
+ const out = [];
291
+ for (const s of 서버들) {
292
+ for (const t of s.도구) {
293
+ out.push({
294
+ type: 'function',
295
+ function: {
296
+ name: 도구이름(s.이름, t.name),
297
+ description: `[${s.이름}] ${t.description ?? t.name}`,
298
+ parameters: t.inputSchema ?? { type: 'object', properties: {} },
299
+ },
300
+ });
301
+ }
302
+ }
303
+ return out;
304
+ }