@mandujs/core 0.9.45 → 0.10.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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/brain/doctor/config-analyzer.ts +498 -0
  3. package/src/brain/doctor/index.ts +10 -0
  4. package/src/change/snapshot.ts +46 -1
  5. package/src/change/types.ts +13 -0
  6. package/src/config/index.ts +8 -2
  7. package/src/config/mcp-ref.ts +348 -0
  8. package/src/config/mcp-status.ts +348 -0
  9. package/src/config/metadata.test.ts +308 -0
  10. package/src/config/metadata.ts +293 -0
  11. package/src/config/symbols.ts +144 -0
  12. package/src/contract/index.ts +26 -25
  13. package/src/contract/protection.ts +364 -0
  14. package/src/error/domains.ts +265 -0
  15. package/src/error/index.ts +25 -13
  16. package/src/filling/filling.ts +88 -6
  17. package/src/guard/analyzer.ts +7 -2
  18. package/src/guard/config-guard.ts +281 -0
  19. package/src/guard/decision-memory.test.ts +293 -0
  20. package/src/guard/decision-memory.ts +532 -0
  21. package/src/guard/healing.test.ts +259 -0
  22. package/src/guard/healing.ts +874 -0
  23. package/src/guard/index.ts +119 -0
  24. package/src/guard/negotiation.test.ts +282 -0
  25. package/src/guard/negotiation.ts +975 -0
  26. package/src/guard/semantic-slots.test.ts +379 -0
  27. package/src/guard/semantic-slots.ts +796 -0
  28. package/src/index.ts +2 -0
  29. package/src/lockfile/generate.ts +259 -0
  30. package/src/lockfile/index.ts +186 -0
  31. package/src/lockfile/lockfile.test.ts +410 -0
  32. package/src/lockfile/types.ts +184 -0
  33. package/src/lockfile/validate.ts +308 -0
  34. package/src/runtime/security.ts +155 -0
  35. package/src/runtime/server.ts +320 -258
  36. package/src/utils/differ.test.ts +342 -0
  37. package/src/utils/differ.ts +482 -0
  38. package/src/utils/hasher.test.ts +326 -0
  39. package/src/utils/hasher.ts +319 -0
  40. package/src/utils/index.ts +29 -0
  41. package/src/utils/safe-io.ts +188 -0
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Mandu MCP 서버 참조 헬퍼 🔗
3
+ *
4
+ * MCP 서버 설정에 메타데이터를 부착하는 편의 함수들
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { z } from "zod";
9
+ * import { mcpServerRef, sensitiveToken, envValue } from "./mcp-ref";
10
+ *
11
+ * const mcpConfigSchema = z.object({
12
+ * sequential: mcpServerRef("sequential-thinking"),
13
+ * apiKey: sensitiveToken(),
14
+ * baseUrl: envValue("API_BASE_URL", "http://localhost:3000"),
15
+ * });
16
+ * ```
17
+ */
18
+
19
+ import { z } from "zod";
20
+ import { withMetadata, withMetadataMultiple } from "./metadata.js";
21
+ import {
22
+ SCHEMA_REFERENCE,
23
+ SENSITIVE_FIELD,
24
+ FIELD_SOURCE,
25
+ PROTECTED_FIELD,
26
+ MCP_SERVER_STATUS,
27
+ RUNTIME_INJECTED,
28
+ type SchemaReferenceMetadata,
29
+ type SensitiveFieldMetadata,
30
+ type FieldSourceMetadata,
31
+ type ProtectedFieldMetadata,
32
+ type McpServerStatusMetadata,
33
+ } from "./symbols.js";
34
+
35
+ // ============================================
36
+ // MCP 서버 참조
37
+ // ============================================
38
+
39
+ /**
40
+ * MCP 서버 참조 스키마 생성
41
+ *
42
+ * @param serverName 참조할 MCP 서버 이름
43
+ * @param optional 선택적 참조 여부
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * const schema = z.object({
48
+ * mcpServer: mcpServerRef("sequential-thinking"),
49
+ * });
50
+ * ```
51
+ */
52
+ export function mcpServerRef(
53
+ serverName: string,
54
+ optional: boolean = false
55
+ ): z.ZodString {
56
+ const schema = z.string();
57
+
58
+ return withMetadata(schema, SCHEMA_REFERENCE, {
59
+ type: "mcpServer",
60
+ name: serverName,
61
+ optional,
62
+ } satisfies SchemaReferenceMetadata);
63
+ }
64
+
65
+ /**
66
+ * MCP 서버 상태를 포함한 참조 스키마
67
+ */
68
+ export function mcpServerWithStatus(
69
+ serverName: string,
70
+ initialStatus: McpServerStatusMetadata["status"] = "unknown"
71
+ ): z.ZodString {
72
+ const schema = z.string();
73
+
74
+ return withMetadataMultiple(schema, [
75
+ [
76
+ SCHEMA_REFERENCE,
77
+ {
78
+ type: "mcpServer",
79
+ name: serverName,
80
+ optional: false,
81
+ } satisfies SchemaReferenceMetadata,
82
+ ],
83
+ [
84
+ MCP_SERVER_STATUS,
85
+ {
86
+ status: initialStatus,
87
+ } satisfies McpServerStatusMetadata,
88
+ ],
89
+ ]);
90
+ }
91
+
92
+ // ============================================
93
+ // 민감 정보 스키마
94
+ // ============================================
95
+
96
+ /**
97
+ * 민감한 토큰/시크릿 스키마
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const schema = z.object({
102
+ * apiKey: sensitiveToken(),
103
+ * password: sensitiveToken("password"),
104
+ * });
105
+ * ```
106
+ */
107
+ export function sensitiveToken(
108
+ fieldName?: string
109
+ ): z.ZodString {
110
+ const schema = z.string();
111
+
112
+ return withMetadataMultiple(schema, [
113
+ [
114
+ SENSITIVE_FIELD,
115
+ {
116
+ redactIn: ["log", "diff", "snapshot"],
117
+ mask: "***",
118
+ } satisfies SensitiveFieldMetadata,
119
+ ],
120
+ [
121
+ PROTECTED_FIELD,
122
+ {
123
+ reason: `Sensitive ${fieldName ?? "token"} - should not be modified by AI`,
124
+ allowedModifiers: ["human"],
125
+ } satisfies ProtectedFieldMetadata,
126
+ ],
127
+ ]);
128
+ }
129
+
130
+ /**
131
+ * 선택적 민감 토큰 스키마
132
+ */
133
+ export function optionalSensitiveToken(): z.ZodOptional<z.ZodString> {
134
+ return sensitiveToken().optional();
135
+ }
136
+
137
+ // ============================================
138
+ // 환경 변수 스키마
139
+ // ============================================
140
+
141
+ /**
142
+ * 환경 변수에서 오는 값 스키마
143
+ *
144
+ * @param envKey 환경 변수 키
145
+ * @param defaultValue 기본값
146
+ *
147
+ * @example
148
+ * ```typescript
149
+ * const schema = z.object({
150
+ * port: envValue("PORT", "3000").transform(Number),
151
+ * apiUrl: envValue("API_URL"),
152
+ * });
153
+ * ```
154
+ */
155
+ export function envValue(
156
+ envKey: string,
157
+ defaultValue?: string
158
+ ): z.ZodString {
159
+ const schema = z.string();
160
+
161
+ return withMetadata(schema, FIELD_SOURCE, {
162
+ source: "env",
163
+ key: envKey,
164
+ fallback: defaultValue,
165
+ } satisfies FieldSourceMetadata);
166
+ }
167
+
168
+ /**
169
+ * 민감한 환경 변수 스키마 (토큰, 비밀키 등)
170
+ */
171
+ export function sensitiveEnvValue(envKey: string): z.ZodString {
172
+ const schema = z.string();
173
+
174
+ return withMetadataMultiple(schema, [
175
+ [
176
+ FIELD_SOURCE,
177
+ {
178
+ source: "env",
179
+ key: envKey,
180
+ } satisfies FieldSourceMetadata,
181
+ ],
182
+ [
183
+ SENSITIVE_FIELD,
184
+ {
185
+ redactIn: ["log", "diff", "snapshot"],
186
+ mask: "***",
187
+ } satisfies SensitiveFieldMetadata,
188
+ ],
189
+ ]);
190
+ }
191
+
192
+ // ============================================
193
+ // 보호된 필드 스키마
194
+ // ============================================
195
+
196
+ /**
197
+ * AI 수정 불가 필드 스키마
198
+ *
199
+ * @param reason 보호 이유
200
+ *
201
+ * @example
202
+ * ```typescript
203
+ * const schema = z.object({
204
+ * securityLevel: protectedField("Security configuration"),
205
+ * });
206
+ * ```
207
+ */
208
+ export function protectedField(reason: string): z.ZodString {
209
+ const schema = z.string();
210
+
211
+ return withMetadata(schema, PROTECTED_FIELD, {
212
+ reason,
213
+ allowedModifiers: ["human"],
214
+ } satisfies ProtectedFieldMetadata);
215
+ }
216
+
217
+ /**
218
+ * 숫자 타입의 보호된 필드
219
+ */
220
+ export function protectedNumber(reason: string): z.ZodNumber {
221
+ const schema = z.number();
222
+
223
+ return withMetadata(schema, PROTECTED_FIELD, {
224
+ reason,
225
+ allowedModifiers: ["human"],
226
+ } satisfies ProtectedFieldMetadata);
227
+ }
228
+
229
+ /**
230
+ * 불리언 타입의 보호된 필드
231
+ */
232
+ export function protectedBoolean(reason: string): z.ZodBoolean {
233
+ const schema = z.boolean();
234
+
235
+ return withMetadata(schema, PROTECTED_FIELD, {
236
+ reason,
237
+ allowedModifiers: ["human"],
238
+ } satisfies ProtectedFieldMetadata);
239
+ }
240
+
241
+ // ============================================
242
+ // 런타임 주입 스키마
243
+ // ============================================
244
+
245
+ /**
246
+ * 런타임에 주입되는 값 스키마
247
+ *
248
+ * @example
249
+ * ```typescript
250
+ * const schema = z.object({
251
+ * requestId: runtimeInjected(z.string()),
252
+ * userId: runtimeInjected(z.string().optional()),
253
+ * });
254
+ * ```
255
+ */
256
+ export function runtimeInjected<T extends z.ZodType>(schema: T): T {
257
+ return withMetadata(schema, RUNTIME_INJECTED, true);
258
+ }
259
+
260
+ // ============================================
261
+ // 복합 스키마 헬퍼
262
+ // ============================================
263
+
264
+ /**
265
+ * MCP 서버 설정 스키마 생성
266
+ *
267
+ * @example
268
+ * ```typescript
269
+ * const mcpServerSchema = createMcpServerSchema();
270
+ * // { command: string, args?: string[], env?: Record<string, string> }
271
+ * ```
272
+ */
273
+ export function createMcpServerSchema() {
274
+ return z.object({
275
+ /** 실행 명령어 */
276
+ command: z.string(),
277
+ /** 명령어 인자 */
278
+ args: z.array(z.string()).optional(),
279
+ /** 환경 변수 */
280
+ env: z.record(z.string()).optional(),
281
+ /** 서버 URL (stdio 대신 HTTP 사용 시) */
282
+ url: z.string().url().optional(),
283
+ /** 버전 */
284
+ version: z.string().optional(),
285
+ });
286
+ }
287
+
288
+ /**
289
+ * 민감 정보가 포함된 MCP 서버 설정 스키마
290
+ */
291
+ export function createMcpServerSchemaWithSecrets() {
292
+ return z.object({
293
+ command: z.string(),
294
+ args: z.array(z.string()).optional(),
295
+ env: z
296
+ .record(
297
+ withMetadata(z.string(), SENSITIVE_FIELD, {
298
+ redactIn: ["log", "diff"],
299
+ })
300
+ )
301
+ .optional(),
302
+ url: z.string().url().optional(),
303
+ token: sensitiveToken("MCP server token").optional(),
304
+ apiKey: sensitiveToken("MCP server API key").optional(),
305
+ });
306
+ }
307
+
308
+ // ============================================
309
+ // 유틸리티
310
+ // ============================================
311
+
312
+ /**
313
+ * 스키마가 MCP 서버 참조인지 확인
314
+ */
315
+ export function isMcpServerRef(schema: z.ZodType): boolean {
316
+ const ref = (schema as any)[SCHEMA_REFERENCE] as SchemaReferenceMetadata | undefined;
317
+ return ref?.type === "mcpServer";
318
+ }
319
+
320
+ /**
321
+ * 스키마에서 MCP 서버 이름 추출
322
+ */
323
+ export function getMcpServerName(schema: z.ZodType): string | undefined {
324
+ const ref = (schema as any)[SCHEMA_REFERENCE] as SchemaReferenceMetadata | undefined;
325
+ return ref?.type === "mcpServer" ? ref.name : undefined;
326
+ }
327
+
328
+ /**
329
+ * 스키마가 민감 필드인지 확인
330
+ */
331
+ export function isSensitiveField(schema: z.ZodType): boolean {
332
+ return SENSITIVE_FIELD in (schema as any);
333
+ }
334
+
335
+ /**
336
+ * 스키마가 보호된 필드인지 확인
337
+ */
338
+ export function isProtectedField(schema: z.ZodType): boolean {
339
+ return PROTECTED_FIELD in (schema as any);
340
+ }
341
+
342
+ /**
343
+ * 스키마가 환경 변수 기반인지 확인
344
+ */
345
+ export function isEnvBasedField(schema: z.ZodType): boolean {
346
+ const source = (schema as any)[FIELD_SOURCE] as FieldSourceMetadata | undefined;
347
+ return source?.source === "env";
348
+ }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * MCP Server Status Tracker
3
+ *
4
+ * Symbol 메타데이터를 사용하여 MCP 서버의 연결 상태를 추적
5
+ *
6
+ * @see docs/plans/09_lockfile_integration_plan.md
7
+ */
8
+
9
+ import {
10
+ MCP_SERVER_STATUS,
11
+ type McpServerStatusMetadata,
12
+ } from "./symbols.js";
13
+
14
+ // ============================================
15
+ // 타입
16
+ // ============================================
17
+
18
+ export interface McpServerInfo {
19
+ /** 서버 이름 */
20
+ name: string;
21
+ /** 연결 상태 */
22
+ status: McpServerStatusMetadata["status"];
23
+ /** 마지막 체크 시각 */
24
+ lastCheck?: string;
25
+ /** 오류 메시지 */
26
+ error?: string;
27
+ /** 서버 버전 */
28
+ version?: string;
29
+ /** 추가 메타데이터 */
30
+ metadata?: Record<string, unknown>;
31
+ }
32
+
33
+ export interface McpStatusSummary {
34
+ /** 전체 서버 수 */
35
+ total: number;
36
+ /** 연결된 서버 수 */
37
+ connected: number;
38
+ /** 연결 해제된 서버 수 */
39
+ disconnected: number;
40
+ /** 오류 상태 서버 수 */
41
+ error: number;
42
+ /** 알 수 없는 상태 서버 수 */
43
+ unknown: number;
44
+ }
45
+
46
+ export interface McpStatusChangeEvent {
47
+ /** 서버 이름 */
48
+ serverName: string;
49
+ /** 이전 상태 */
50
+ previousStatus: McpServerStatusMetadata["status"];
51
+ /** 현재 상태 */
52
+ currentStatus: McpServerStatusMetadata["status"];
53
+ /** 변경 시각 */
54
+ timestamp: string;
55
+ /** 오류 (있는 경우) */
56
+ error?: string;
57
+ }
58
+
59
+ export type McpStatusListener = (event: McpStatusChangeEvent) => void;
60
+
61
+ // ============================================
62
+ // Status Tracker
63
+ // ============================================
64
+
65
+ /**
66
+ * MCP Server Status Tracker
67
+ *
68
+ * 싱글톤 패턴으로 MCP 서버 상태를 중앙 관리
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * const tracker = getMcpStatusTracker();
73
+ *
74
+ * // 상태 업데이트
75
+ * tracker.updateStatus("sequential-thinking", "connected");
76
+ *
77
+ * // 상태 조회
78
+ * const status = tracker.getStatus("sequential-thinking");
79
+ * console.log(status); // { status: "connected", lastCheck: "..." }
80
+ *
81
+ * // 이벤트 리스너
82
+ * tracker.onStatusChange((event) => {
83
+ * console.log(`${event.serverName}: ${event.previousStatus} → ${event.currentStatus}`);
84
+ * });
85
+ * ```
86
+ */
87
+ export class McpStatusTracker {
88
+ private statuses = new Map<string, McpServerInfo>();
89
+ private listeners: McpStatusListener[] = [];
90
+
91
+ /**
92
+ * 서버 상태 업데이트
93
+ */
94
+ updateStatus(
95
+ serverName: string,
96
+ status: McpServerStatusMetadata["status"],
97
+ options?: {
98
+ error?: string;
99
+ version?: string;
100
+ metadata?: Record<string, unknown>;
101
+ }
102
+ ): void {
103
+ const previous = this.statuses.get(serverName);
104
+ const previousStatus = previous?.status ?? "unknown";
105
+ const now = new Date().toISOString();
106
+
107
+ const newInfo: McpServerInfo = {
108
+ name: serverName,
109
+ status,
110
+ lastCheck: now,
111
+ error: options?.error,
112
+ version: options?.version ?? previous?.version,
113
+ metadata: options?.metadata ?? previous?.metadata,
114
+ };
115
+
116
+ this.statuses.set(serverName, newInfo);
117
+
118
+ // 상태가 변경된 경우 이벤트 발행
119
+ if (previousStatus !== status) {
120
+ const event: McpStatusChangeEvent = {
121
+ serverName,
122
+ previousStatus,
123
+ currentStatus: status,
124
+ timestamp: now,
125
+ error: options?.error,
126
+ };
127
+
128
+ this.notifyListeners(event);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 서버 상태 조회
134
+ */
135
+ getStatus(serverName: string): McpServerInfo | undefined {
136
+ return this.statuses.get(serverName);
137
+ }
138
+
139
+ /**
140
+ * 모든 서버 상태 조회
141
+ */
142
+ getAllStatuses(): McpServerInfo[] {
143
+ return Array.from(this.statuses.values());
144
+ }
145
+
146
+ /**
147
+ * 상태 요약 조회
148
+ */
149
+ getSummary(): McpStatusSummary {
150
+ const servers = Array.from(this.statuses.values());
151
+
152
+ return {
153
+ total: servers.length,
154
+ connected: servers.filter(s => s.status === "connected").length,
155
+ disconnected: servers.filter(s => s.status === "disconnected").length,
156
+ error: servers.filter(s => s.status === "error").length,
157
+ unknown: servers.filter(s => s.status === "unknown").length,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * 서버 등록 (초기 상태: unknown)
163
+ */
164
+ registerServer(
165
+ serverName: string,
166
+ metadata?: Record<string, unknown>
167
+ ): void {
168
+ if (!this.statuses.has(serverName)) {
169
+ this.statuses.set(serverName, {
170
+ name: serverName,
171
+ status: "unknown",
172
+ metadata,
173
+ });
174
+ }
175
+ }
176
+
177
+ /**
178
+ * 서버 등록 해제
179
+ */
180
+ unregisterServer(serverName: string): boolean {
181
+ return this.statuses.delete(serverName);
182
+ }
183
+
184
+ /**
185
+ * 상태 변경 리스너 등록
186
+ */
187
+ onStatusChange(listener: McpStatusListener): () => void {
188
+ this.listeners.push(listener);
189
+
190
+ // 등록 해제 함수 반환
191
+ return () => {
192
+ const index = this.listeners.indexOf(listener);
193
+ if (index > -1) {
194
+ this.listeners.splice(index, 1);
195
+ }
196
+ };
197
+ }
198
+
199
+ /**
200
+ * 모든 리스너에 이벤트 전달
201
+ */
202
+ private notifyListeners(event: McpStatusChangeEvent): void {
203
+ for (const listener of this.listeners) {
204
+ try {
205
+ listener(event);
206
+ } catch (error) {
207
+ console.error("[McpStatusTracker] Listener error:", error);
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * 모든 상태 초기화
214
+ */
215
+ clear(): void {
216
+ this.statuses.clear();
217
+ }
218
+ }
219
+
220
+ // ============================================
221
+ // 싱글톤 인스턴스
222
+ // ============================================
223
+
224
+ let trackerInstance: McpStatusTracker | null = null;
225
+
226
+ /**
227
+ * MCP Status Tracker 싱글톤 인스턴스 획득
228
+ */
229
+ export function getMcpStatusTracker(): McpStatusTracker {
230
+ if (!trackerInstance) {
231
+ trackerInstance = new McpStatusTracker();
232
+ }
233
+ return trackerInstance;
234
+ }
235
+
236
+ /**
237
+ * 테스트용 인스턴스 리셋
238
+ */
239
+ export function resetMcpStatusTracker(): void {
240
+ trackerInstance = null;
241
+ }
242
+
243
+ // ============================================
244
+ // 유틸리티
245
+ // ============================================
246
+
247
+ /**
248
+ * MCP 서버 상태 확인 (간단한 ping)
249
+ *
250
+ * 실제 MCP 프로토콜 체크가 아닌 프로세스 존재 여부 확인
251
+ */
252
+ export async function checkMcpServerStatus(
253
+ serverConfig: { command: string; args?: string[] }
254
+ ): Promise<McpServerStatusMetadata["status"]> {
255
+ try {
256
+ // 간단한 command 존재 확인
257
+ const proc = Bun.spawn([serverConfig.command, "--version"], {
258
+ stdout: "pipe",
259
+ stderr: "pipe",
260
+ });
261
+
262
+ const exitCode = await proc.exited;
263
+ return exitCode === 0 ? "connected" : "error";
264
+ } catch {
265
+ return "disconnected";
266
+ }
267
+ }
268
+
269
+ /**
270
+ * 여러 MCP 서버 상태 일괄 확인
271
+ */
272
+ export async function checkAllMcpServers(
273
+ servers: Record<string, { command: string; args?: string[] }>
274
+ ): Promise<Record<string, McpServerStatusMetadata>> {
275
+ const results: Record<string, McpServerStatusMetadata> = {};
276
+ const tracker = getMcpStatusTracker();
277
+
278
+ await Promise.all(
279
+ Object.entries(servers).map(async ([name, config]) => {
280
+ const status = await checkMcpServerStatus(config);
281
+ tracker.updateStatus(name, status);
282
+
283
+ results[name] = {
284
+ status,
285
+ lastChecked: new Date().toISOString(),
286
+ };
287
+ })
288
+ );
289
+
290
+ return results;
291
+ }
292
+
293
+ // ============================================
294
+ // 포맷팅
295
+ // ============================================
296
+
297
+ /**
298
+ * MCP 상태 요약을 콘솔 출력용 문자열로 변환
299
+ */
300
+ export function formatMcpStatusSummary(summary: McpStatusSummary): string {
301
+ const lines: string[] = [];
302
+
303
+ lines.push("🔌 MCP Server Status");
304
+ lines.push("───────────────────");
305
+ lines.push(` 전체: ${summary.total}개`);
306
+
307
+ if (summary.connected > 0) {
308
+ lines.push(` ✅ 연결됨: ${summary.connected}개`);
309
+ }
310
+ if (summary.disconnected > 0) {
311
+ lines.push(` ⚪ 연결 해제: ${summary.disconnected}개`);
312
+ }
313
+ if (summary.error > 0) {
314
+ lines.push(` ❌ 오류: ${summary.error}개`);
315
+ }
316
+ if (summary.unknown > 0) {
317
+ lines.push(` ❓ 알 수 없음: ${summary.unknown}개`);
318
+ }
319
+
320
+ return lines.join("\n");
321
+ }
322
+
323
+ /**
324
+ * 개별 서버 상태를 문자열로 변환
325
+ */
326
+ export function formatMcpServerStatus(info: McpServerInfo): string {
327
+ const icon = getStatusIcon(info.status);
328
+ let line = `${icon} ${info.name}`;
329
+
330
+ if (info.version) {
331
+ line += ` (v${info.version})`;
332
+ }
333
+
334
+ if (info.error) {
335
+ line += ` - ${info.error}`;
336
+ }
337
+
338
+ return line;
339
+ }
340
+
341
+ function getStatusIcon(status: McpServerStatusMetadata["status"]): string {
342
+ switch (status) {
343
+ case "connected": return "✅";
344
+ case "disconnected": return "⚪";
345
+ case "error": return "❌";
346
+ case "unknown": return "❓";
347
+ }
348
+ }