junis 0.2.6 → 0.3.2

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.
@@ -30,8 +30,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/server/mcp.ts
31
31
  var mcp_exports = {};
32
32
  __export(mcp_exports, {
33
+ checkPermission: () => checkPermission,
33
34
  handleMCPRequest: () => handleMCPRequest,
34
- startMCPServer: () => startMCPServer
35
+ startMCPServer: () => startMCPServer,
36
+ toolPermissions: () => toolPermissions
35
37
  });
36
38
  module.exports = __toCommonJS(mcp_exports);
37
39
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
@@ -45,29 +47,96 @@ var import_promises = __toESM(require("fs/promises"));
45
47
  var import_path = __toESM(require("path"));
46
48
  var import_glob = require("glob");
47
49
  var import_zod = require("zod");
50
+
51
+ // src/server/permissions.ts
52
+ var toolPermissions = {
53
+ // 읽기 전용 — 자동 허용
54
+ browser_snapshot: "auto",
55
+ browser_screenshot: "auto",
56
+ desktop_see: "auto",
57
+ desktop_list_apps: "auto",
58
+ desktop_list_windows: "auto",
59
+ cron_list: "auto",
60
+ read_file: "auto",
61
+ list_directory: "auto",
62
+ list_processes: "auto",
63
+ search_code: "auto",
64
+ // 상호작용 — 확인 권장 (현재: auto와 동일하게 실행, 향후 UI 연동)
65
+ browser_click: "confirm",
66
+ browser_type: "confirm",
67
+ browser_navigate: "confirm",
68
+ browser_fill: "confirm",
69
+ browser_select: "confirm",
70
+ browser_press: "confirm",
71
+ browser_hover: "confirm",
72
+ browser_drag: "confirm",
73
+ browser_upload: "confirm",
74
+ browser_cookies: "confirm",
75
+ browser_storage: "confirm",
76
+ browser_dialog: "confirm",
77
+ desktop_click: "confirm",
78
+ desktop_type: "confirm",
79
+ desktop_hotkey: "confirm",
80
+ desktop_scroll: "confirm",
81
+ desktop_menu: "confirm",
82
+ desktop_screenshot: "confirm",
83
+ cron_create: "confirm",
84
+ cron_delete: "confirm",
85
+ edit_block: "confirm",
86
+ kill_process: "confirm",
87
+ // 시스템 변경 — 기본 차단 (PDF 7.3절)
88
+ execute_command: "deny",
89
+ write_file: "deny"
90
+ };
91
+ function checkPermission(toolName) {
92
+ const level = toolPermissions[toolName];
93
+ if (level === "deny") {
94
+ throw new Error(
95
+ `Tool '${toolName}' is blocked by permission policy (deny). To allow, update toolPermissions in src/server/permissions.ts.`
96
+ );
97
+ }
98
+ }
99
+
100
+ // src/tools/filesystem.ts
48
101
  var execAsync = (0, import_util.promisify)(import_child_process.exec);
49
102
  var execFileAsync = (0, import_util.promisify)(import_child_process.execFile);
50
103
  var FilesystemTools = class {
51
104
  register(server) {
52
105
  server.tool(
53
106
  "execute_command",
54
- "\uD130\uBBF8\uB110 \uBA85\uB839 \uC2E4\uD589",
107
+ [
108
+ "Execute a shell command on the user's local device.",
109
+ "",
110
+ "ROUTING:",
111
+ "- Use for system commands, package managers (npm, pip, brew), git, build tools, and scripting.",
112
+ "- For reading files prefer read_file, for editing prefer edit_block, for searching prefer search_code.",
113
+ "",
114
+ "BEHAVIOR:",
115
+ "- Safe, routine commands (ls, pwd, git status, echo): execute immediately without explanation.",
116
+ "- Destructive or irreversible commands (rm -rf, sudo, shutdown, mkfs): explain what will happen and get user confirmation first.",
117
+ "- If a command fails, analyze the error and suggest an alternative. Do not retry the identical command more than twice.",
118
+ "",
119
+ "SAFETY:",
120
+ "- Commands run with the user's full permissions. Never execute commands that could damage the system, expose credentials, or modify security settings without explicit user request.",
121
+ "- Avoid piping untrusted input into shells. Use absolute paths when possible. Quote paths containing spaces."
122
+ ].join("\n"),
55
123
  {
56
- command: import_zod.z.string().describe("\uC2E4\uD589\uD560 \uC258 \uBA85\uB839"),
57
- timeout_ms: import_zod.z.number().optional().default(3e4).describe("\uD0C0\uC784\uC544\uC6C3 (ms)"),
58
- background: import_zod.z.boolean().optional().default(false).describe("\uBC31\uADF8\uB77C\uC6B4\uB4DC \uC2E4\uD589")
124
+ command: import_zod.z.string().describe("The shell command to execute. Use absolute paths when possible. Quote paths containing spaces."),
125
+ timeout_ms: import_zod.z.number().optional().default(3e4).describe("Maximum execution time in milliseconds (default: 30000). Increase for long-running builds or downloads."),
126
+ background: import_zod.z.boolean().optional().default(false).describe("Run in background without waiting for completion. Use for servers or long-running processes.")
59
127
  },
60
128
  async ({ command, timeout_ms, background }) => {
129
+ checkPermission("execute_command");
61
130
  if (background) {
62
131
  (0, import_child_process.exec)(command);
63
- return { content: [{ type: "text", text: "\uBC31\uADF8\uB77C\uC6B4\uB4DC \uC2E4\uD589 \uC2DC\uC791\uB428" }] };
132
+ return { content: [{ type: "text", text: "Background execution started" }] };
64
133
  }
65
134
  try {
66
135
  const { stdout, stderr } = await execAsync(command, {
67
136
  timeout: timeout_ms
68
137
  });
69
138
  return {
70
- content: [{ type: "text", text: stdout || stderr || "(\uCD9C\uB825 \uC5C6\uC74C)" }]
139
+ content: [{ type: "text", text: stdout || stderr || "(no output)" }]
71
140
  };
72
141
  } catch (err) {
73
142
  const error = err;
@@ -75,7 +144,7 @@ var FilesystemTools = class {
75
144
  content: [
76
145
  {
77
146
  type: "text",
78
- text: `\uC624\uB958 (exit ${error.code ?? "?"}): ${error.message}
147
+ text: `Error (exit ${error.code ?? "?"}): ${error.message}
79
148
  ${error.stderr ?? ""}`
80
149
  }
81
150
  ],
@@ -86,10 +155,15 @@ ${error.stderr ?? ""}`
86
155
  );
87
156
  server.tool(
88
157
  "read_file",
89
- "\uD30C\uC77C \uC77D\uAE30",
158
+ [
159
+ "Read the contents of a file from the local filesystem.",
160
+ "",
161
+ "Returns file content as text (utf-8) or base64 for binary files. Supports any file type.",
162
+ "For searching within files, prefer search_code instead. For listing directory contents, use list_directory."
163
+ ].join("\n"),
90
164
  {
91
- path: import_zod.z.string().describe("\uD30C\uC77C \uACBD\uB85C"),
92
- encoding: import_zod.z.enum(["utf-8", "base64"]).optional().default("utf-8").describe("\uC778\uCF54\uB529")
165
+ path: import_zod.z.string().describe("Absolute or relative file path to read"),
166
+ encoding: import_zod.z.enum(["utf-8", "base64"]).optional().default("utf-8").describe("'utf-8' for text files (default), 'base64' for binary files (images, PDFs, archives)")
93
167
  },
94
168
  async ({ path: filePath, encoding }) => {
95
169
  try {
@@ -98,30 +172,39 @@ ${error.stderr ?? ""}`
98
172
  } catch (err) {
99
173
  const e = err;
100
174
  if (e.code === "ENOENT") {
101
- return { content: [{ type: "text", text: `\u274C \uD30C\uC77C\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${filePath}` }], isError: true };
175
+ return { content: [{ type: "text", text: `\u274C File not found: ${filePath}` }], isError: true };
102
176
  }
103
- return { content: [{ type: "text", text: `\u274C \uD30C\uC77C \uC77D\uAE30 \uC2E4\uD328: ${e.message}` }], isError: true };
177
+ return { content: [{ type: "text", text: `\u274C Failed to read file: ${e.message}` }], isError: true };
104
178
  }
105
179
  }
106
180
  );
107
181
  server.tool(
108
182
  "write_file",
109
- "\uD30C\uC77C \uC4F0\uAE30/\uC0DD\uC131",
183
+ [
184
+ "Create a new file or completely overwrite an existing file. Parent directories are created automatically.",
185
+ "",
186
+ "WARNING: This replaces the entire file content. For partial modifications, use edit_block instead.",
187
+ "Prefer edit_block over write_file for existing files \u2014 it's safer and preserves unmodified content."
188
+ ].join("\n"),
110
189
  {
111
- path: import_zod.z.string().describe("\uD30C\uC77C \uACBD\uB85C"),
112
- content: import_zod.z.string().describe("\uD30C\uC77C \uB0B4\uC6A9")
190
+ path: import_zod.z.string().describe("File path to create or overwrite. Parent directories are auto-created."),
191
+ content: import_zod.z.string().describe("Complete file content. This replaces the entire file.")
113
192
  },
114
193
  async ({ path: filePath, content }) => {
194
+ checkPermission("write_file");
115
195
  await import_promises.default.mkdir(import_path.default.dirname(filePath), { recursive: true });
116
196
  await import_promises.default.writeFile(filePath, content, "utf-8");
117
- return { content: [{ type: "text", text: "\uD30C\uC77C \uC800\uC7A5 \uC644\uB8CC" }] };
197
+ return { content: [{ type: "text", text: "File saved" }] };
118
198
  }
119
199
  );
120
200
  server.tool(
121
201
  "list_directory",
122
- "\uB514\uB809\uD1A0\uB9AC \uBAA9\uB85D \uC870\uD68C",
202
+ [
203
+ "List files and subdirectories in the specified path. Returns entries with type indicators (\u{1F4C1} directory, \u{1F4C4} file).",
204
+ "Use this to explore project structure before reading or modifying files."
205
+ ].join("\n"),
123
206
  {
124
- path: import_zod.z.string().describe("\uB514\uB809\uD1A0\uB9AC \uACBD\uB85C")
207
+ path: import_zod.z.string().describe("Directory path to list")
125
208
  },
126
209
  async ({ path: dirPath }) => {
127
210
  try {
@@ -131,19 +214,24 @@ ${error.stderr ?? ""}`
131
214
  } catch (err) {
132
215
  const e = err;
133
216
  if (e.code === "ENOENT") {
134
- return { content: [{ type: "text", text: `\u274C \uB514\uB809\uD1A0\uB9AC\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${dirPath}` }], isError: true };
217
+ return { content: [{ type: "text", text: `\u274C Directory not found: ${dirPath}` }], isError: true };
135
218
  }
136
- return { content: [{ type: "text", text: `\u274C \uB514\uB809\uD1A0\uB9AC \uC77D\uAE30 \uC2E4\uD328: ${e.message}` }], isError: true };
219
+ return { content: [{ type: "text", text: `\u274C Failed to read directory: ${e.message}` }], isError: true };
137
220
  }
138
221
  }
139
222
  );
140
223
  server.tool(
141
224
  "search_code",
142
- "\uCF54\uB4DC/\uD14D\uC2A4\uD2B8 \uAC80\uC0C9",
225
+ [
226
+ "Search for text patterns across files using regex. Uses ripgrep for speed with glob fallback.",
227
+ "",
228
+ "Use this to find code definitions, function references, configuration values, or any text pattern.",
229
+ "Returns matching lines with file paths and line numbers for precise navigation."
230
+ ].join("\n"),
143
231
  {
144
- pattern: import_zod.z.string().describe("\uAC80\uC0C9 \uD328\uD134 (\uC815\uADDC\uC2DD \uC9C0\uC6D0)"),
145
- directory: import_zod.z.string().optional().default(".").describe("\uAC80\uC0C9 \uB514\uB809\uD1A0\uB9AC"),
146
- file_pattern: import_zod.z.string().optional().default("**/*").describe("\uD30C\uC77C \uD328\uD134")
232
+ pattern: import_zod.z.string().describe("Search pattern with full regex support (e.g. 'function\\s+\\w+', 'import.*from', 'TODO')"),
233
+ directory: import_zod.z.string().optional().default(".").describe("Root directory to search from (default: current working directory)"),
234
+ file_pattern: import_zod.z.string().optional().default("**/*").describe("Glob pattern to filter files (e.g. '**/*.ts', '*.py', 'src/**/*.js')")
147
235
  },
148
236
  async ({ pattern, directory, file_pattern }) => {
149
237
  try {
@@ -152,7 +240,7 @@ ${error.stderr ?? ""}`
152
240
  ["--no-heading", "-n", pattern, directory],
153
241
  { timeout: 1e4 }
154
242
  );
155
- return { content: [{ type: "text", text: stdout || "\uACB0\uACFC \uC5C6\uC74C" }] };
243
+ return { content: [{ type: "text", text: stdout || "No results" }] };
156
244
  } catch {
157
245
  const safeDirectory = import_path.default.resolve(directory);
158
246
  const files = await (0, import_glob.glob)(file_pattern, { cwd: safeDirectory });
@@ -173,7 +261,7 @@ ${error.stderr ?? ""}`
173
261
  }
174
262
  return {
175
263
  content: [
176
- { type: "text", text: results.join("\n") || "\uACB0\uACFC \uC5C6\uC74C" }
264
+ { type: "text", text: results.join("\n") || "No results" }
177
265
  ]
178
266
  };
179
267
  }
@@ -181,7 +269,7 @@ ${error.stderr ?? ""}`
181
269
  );
182
270
  server.tool(
183
271
  "list_processes",
184
- "\uC2E4\uD589 \uC911\uC778 \uD504\uB85C\uC138\uC2A4 \uBAA9\uB85D",
272
+ "List the top 30 running processes sorted by CPU usage. Use this to identify resource-heavy processes, find PIDs for kill_process, or diagnose performance issues.",
185
273
  {},
186
274
  async () => {
187
275
  const cmd = process.platform === "win32" ? "tasklist" : process.platform === "darwin" ? "ps aux | sort -rk 3 | head -30" : "ps aux --sort=-%cpu | head -30";
@@ -191,23 +279,27 @@ ${error.stderr ?? ""}`
191
279
  );
192
280
  server.tool(
193
281
  "kill_process",
194
- "\uD504\uB85C\uC138\uC2A4 \uC885\uB8CC (SIGTERM \uD6C4 3\uCD08 \uB300\uAE30, \uC0B4\uC544\uC788\uC73C\uBA74 SIGKILL \uC790\uB3D9 \uC801\uC6A9)",
282
+ [
283
+ "Terminate a process by PID. Default: sends SIGTERM (graceful shutdown), waits 3 seconds, then auto-applies SIGKILL if still alive.",
284
+ "",
285
+ "SAFETY: Only kill processes the user explicitly identifies. Never kill system-critical processes (init, systemd, loginwindow, WindowServer) without explicit instruction."
286
+ ].join("\n"),
195
287
  {
196
- pid: import_zod.z.number().describe("\uC885\uB8CC\uD560 \uD504\uB85C\uC138\uC2A4 PID"),
197
- signal: import_zod.z.enum(["SIGTERM", "SIGKILL"]).optional().default("SIGTERM").describe("\uCD08\uAE30 \uC2DC\uADF8\uB110 (\uAE30\uBCF8\uAC12: SIGTERM). SIGKILL \uC9C0\uC815 \uC2DC \uC989\uC2DC \uAC15\uC81C \uC885\uB8CC)")
288
+ pid: import_zod.z.number().describe("PID of the process to terminate (use list_processes to find PIDs)"),
289
+ signal: import_zod.z.enum(["SIGTERM", "SIGKILL"]).optional().default("SIGTERM").describe("SIGTERM (default): graceful shutdown with 3s auto-SIGKILL fallback. SIGKILL: immediate force kill.")
198
290
  },
199
291
  async ({ pid, signal }) => {
200
292
  const isWindows = process.platform === "win32";
201
293
  if (isWindows) {
202
294
  await execAsync(`taskkill /PID ${pid} /F`);
203
295
  return {
204
- content: [{ type: "text", text: `PID ${pid} \uC885\uB8CC \uC644\uB8CC (taskkill /F)` }]
296
+ content: [{ type: "text", text: `PID ${pid} killed (taskkill /F)` }]
205
297
  };
206
298
  }
207
299
  if (signal === "SIGKILL") {
208
300
  await execAsync(`kill -9 ${pid}`);
209
301
  return {
210
- content: [{ type: "text", text: `PID ${pid} \uAC15\uC81C \uC885\uB8CC \uC644\uB8CC (SIGKILL)` }]
302
+ content: [{ type: "text", text: `PID ${pid} force killed (SIGKILL)` }]
211
303
  };
212
304
  }
213
305
  try {
@@ -215,7 +307,7 @@ ${error.stderr ?? ""}`
215
307
  } catch {
216
308
  return {
217
309
  content: [
218
- { type: "text", text: `PID ${pid} \uC885\uB8CC \uC2E4\uD328: \uD504\uB85C\uC138\uC2A4\uAC00 \uC874\uC7AC\uD558\uC9C0 \uC54A\uAC70\uB098 \uAD8C\uD55C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.` }
310
+ { type: "text", text: `PID ${pid} kill failed: process does not exist or permission denied.` }
219
311
  ],
220
312
  isError: true
221
313
  };
@@ -224,7 +316,7 @@ ${error.stderr ?? ""}`
224
316
  const isAlive = await execAsync(`kill -0 ${pid}`).then(() => true).catch(() => false);
225
317
  if (!isAlive) {
226
318
  return {
227
- content: [{ type: "text", text: `PID ${pid} \uC885\uB8CC \uC644\uB8CC (SIGTERM)` }]
319
+ content: [{ type: "text", text: `PID ${pid} killed (SIGTERM)` }]
228
320
  };
229
321
  }
230
322
  await execAsync(`kill -9 ${pid}`);
@@ -232,7 +324,7 @@ ${error.stderr ?? ""}`
232
324
  content: [
233
325
  {
234
326
  type: "text",
235
- text: `PID ${pid} \uAC15\uC81C \uC885\uB8CC \uC644\uB8CC (SIGTERM \uBB34\uC751\uB2F5 \u2192 SIGKILL \uC790\uB3D9 \uC801\uC6A9)`
327
+ text: `PID ${pid} force killed (SIGTERM unresponsive, auto SIGKILL applied)`
236
328
  }
237
329
  ]
238
330
  };
@@ -240,17 +332,25 @@ ${error.stderr ?? ""}`
240
332
  );
241
333
  server.tool(
242
334
  "edit_block",
243
- "\uD30C\uC77C\uC758 \uD2B9\uC815 \uD14D\uC2A4\uD2B8 \uBE14\uB85D\uC744 \uC0C8 \uD14D\uC2A4\uD2B8\uB85C \uAD50\uCCB4 (diff \uAE30\uBC18 \uBD80\uBD84 \uC218\uC815)",
335
+ [
336
+ "Replace a specific text block in a file with new text (diff-based partial edit).",
337
+ "",
338
+ "WORKFLOW: Always use read_file first to see current content, then use edit_block with the exact text to replace.",
339
+ "The old_string must match character-for-character including whitespace, indentation, and line breaks.",
340
+ "If multiple matches exist, include more surrounding context to make it unique, or set replace_all=true.",
341
+ "",
342
+ "Prefer this over write_file for modifying existing files \u2014 it only changes what you specify and preserves the rest."
343
+ ].join("\n"),
244
344
  {
245
- path: import_zod.z.string().describe("\uD30C\uC77C \uACBD\uB85C"),
246
- old_string: import_zod.z.string().describe("\uAD50\uCCB4\uD560 \uAE30\uC874 \uD14D\uC2A4\uD2B8 (\uC815\uD655\uD788 \uC77C\uCE58\uD574\uC57C \uD568)"),
247
- new_string: import_zod.z.string().describe("\uC0C8 \uD14D\uC2A4\uD2B8"),
248
- replace_all: import_zod.z.boolean().optional().default(false).describe("true\uBA74 \uBAA8\uB4E0 \uB9E4\uCE6D \uAD50\uCCB4, false\uBA74 \uCCAB \uBC88\uC9F8\uB9CC")
345
+ path: import_zod.z.string().describe("Path to the file to edit. The file must already exist."),
346
+ old_string: import_zod.z.string().describe("The exact text to find and replace. Must match character-for-character including whitespace and newlines. Include enough context for uniqueness."),
347
+ new_string: import_zod.z.string().describe("The replacement text. Use empty string to delete the matched text."),
348
+ replace_all: import_zod.z.boolean().optional().default(false).describe("If true, replace ALL matches. If false (default), require exactly one match (errors on ambiguous multiple matches).")
249
349
  },
250
350
  async ({ path: filePath, old_string, new_string, replace_all }) => {
251
351
  const content = await import_promises.default.readFile(filePath, "utf-8");
252
352
  if (!content.includes(old_string)) {
253
- throw new Error(`old_string\uC744 \uD30C\uC77C\uC5D0\uC11C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${filePath}`);
353
+ throw new Error(`old_string not found in file: ${filePath}`);
254
354
  }
255
355
  let count = 0;
256
356
  let pos = 0;
@@ -260,7 +360,7 @@ ${error.stderr ?? ""}`
260
360
  }
261
361
  if (!replace_all && count > 1) {
262
362
  throw new Error(
263
- `\uB9E4\uCE6D\uC774 ${count}\uAC1C\uC785\uB2C8\uB2E4. replace_all\uC744 true\uB85C \uD558\uAC70\uB098 \uB354 \uB113\uC740 \uCEE8\uD14D\uC2A4\uD2B8\uB97C \uD3EC\uD568\uD558\uC138\uC694.`
363
+ `Found ${count} matches. Set replace_all to true or include more context to narrow it down.`
264
364
  );
265
365
  }
266
366
  let result;
@@ -274,21 +374,202 @@ ${error.stderr ?? ""}`
274
374
  }
275
375
  await import_promises.default.writeFile(filePath, result, "utf-8");
276
376
  return {
277
- content: [{ type: "text", text: `\uAD50\uCCB4 \uC644\uB8CC (${replaced}\uAC1C \uBCC0\uACBD\uB428)` }]
377
+ content: [{ type: "text", text: `Replaced (${replaced} occurrence(s) changed)` }]
278
378
  };
279
379
  }
280
380
  );
381
+ server.tool(
382
+ "cron_create",
383
+ [
384
+ "Create a recurring scheduled task (cron job) using standard cron syntax.",
385
+ "",
386
+ "Common schedules: '*/5 * * * *' (every 5 min), '0 9 * * 1-5' (weekdays 9am), '0 0 * * *' (daily midnight), '0 */2 * * *' (every 2 hours).",
387
+ "Duplicate commands are automatically detected and rejected. Use cron_list to see existing jobs."
388
+ ].join("\n"),
389
+ {
390
+ schedule: import_zod.z.string().describe("Cron schedule expression (5 fields: minute hour day month weekday). Examples: '*/5 * * * *' (every 5 min), '0 9 * * 1-5' (weekdays 9am)"),
391
+ command: import_zod.z.string().describe("Shell command to execute on schedule"),
392
+ label: import_zod.z.string().optional().describe("Human-readable label for identification (e.g. 'daily-backup', 'log-cleanup')")
393
+ },
394
+ async ({ schedule, command, label }) => {
395
+ try {
396
+ let existing = "";
397
+ try {
398
+ const { stdout } = await execAsync("crontab -l");
399
+ existing = stdout;
400
+ } catch {
401
+ }
402
+ if (existing.includes(command)) {
403
+ return {
404
+ content: [{ type: "text", text: `\u26A0\uFE0F A cron job with this command already exists.` }],
405
+ isError: true
406
+ };
407
+ }
408
+ const comment = label ? `# junis:${label}
409
+ ` : "# junis-cron\n";
410
+ const newEntry = `${comment}${schedule} ${command}
411
+ `;
412
+ const updated = existing.trimEnd() + "\n" + newEntry;
413
+ const tmpFile = `/tmp/junis_crontab_${Date.now()}.txt`;
414
+ await import_promises.default.writeFile(tmpFile, updated, "utf-8");
415
+ await execAsync(`crontab ${tmpFile}`);
416
+ await import_promises.default.unlink(tmpFile).catch(() => {
417
+ });
418
+ return {
419
+ content: [{ type: "text", text: `\u2705 Cron job created:
420
+ schedule: ${schedule}
421
+ command: ${command}${label ? `
422
+ label: ${label}` : ""}` }]
423
+ };
424
+ } catch (err) {
425
+ return {
426
+ content: [{ type: "text", text: `\u274C Failed to create cron job: ${err.message}` }],
427
+ isError: true
428
+ };
429
+ }
430
+ }
431
+ );
432
+ server.tool(
433
+ "cron_list",
434
+ "List all scheduled cron jobs with their IDs, labels, schedules, and commands. Use the returned ID numbers with cron_delete to remove specific jobs.",
435
+ {},
436
+ async () => {
437
+ try {
438
+ const { stdout } = await execAsync("crontab -l");
439
+ const lines = stdout.trim().split("\n").filter((l) => l.trim());
440
+ if (lines.length === 0) {
441
+ return { content: [{ type: "text", text: "No cron jobs found." }] };
442
+ }
443
+ const entries = [];
444
+ let pendingLabel;
445
+ let id = 1;
446
+ for (const line of lines) {
447
+ if (line.startsWith("#")) {
448
+ const match = line.match(/^# junis:(.+)$/);
449
+ pendingLabel = match ? match[1].trim() : void 0;
450
+ continue;
451
+ }
452
+ const parts = line.split(/\s+/);
453
+ if (parts.length >= 6) {
454
+ const schedule = parts.slice(0, 5).join(" ");
455
+ const command = parts.slice(5).join(" ");
456
+ entries.push({ id: id++, label: pendingLabel, schedule, command });
457
+ }
458
+ pendingLabel = void 0;
459
+ }
460
+ if (entries.length === 0) {
461
+ return { content: [{ type: "text", text: stdout }] };
462
+ }
463
+ const output = entries.map(
464
+ (e) => `[${e.id}] ${e.label ? `(${e.label}) ` : ""}${e.schedule} \u2192 ${e.command}`
465
+ ).join("\n");
466
+ return { content: [{ type: "text", text: output }] };
467
+ } catch (err) {
468
+ const e = err;
469
+ if (e.code === 1) {
470
+ return { content: [{ type: "text", text: "No cron jobs found (crontab is empty)." }] };
471
+ }
472
+ return {
473
+ content: [{ type: "text", text: `\u274C Failed to list cron jobs: ${e.message}` }],
474
+ isError: true
475
+ };
476
+ }
477
+ }
478
+ );
479
+ server.tool(
480
+ "cron_delete",
481
+ "Delete a scheduled cron job by its ID (from cron_list output) or by matching command string. Associated comment labels are automatically cleaned up.",
482
+ {
483
+ id: import_zod.z.number().optional().describe("Cron job ID from cron_list output (e.g. 1, 2, 3)"),
484
+ command: import_zod.z.string().optional().describe("Delete all jobs matching this command string")
485
+ },
486
+ async ({ id, command }) => {
487
+ if (!id && !command) {
488
+ return {
489
+ content: [{ type: "text", text: "\u274C Provide either id or command to identify the cron job." }],
490
+ isError: true
491
+ };
492
+ }
493
+ try {
494
+ let existing = "";
495
+ try {
496
+ const { stdout } = await execAsync("crontab -l");
497
+ existing = stdout;
498
+ } catch {
499
+ return { content: [{ type: "text", text: "No cron jobs to delete." }] };
500
+ }
501
+ const lines = existing.split("\n");
502
+ if (command) {
503
+ const filtered2 = [];
504
+ for (let i = 0; i < lines.length; i++) {
505
+ if (lines[i].includes(command)) {
506
+ if (filtered2.length > 0 && filtered2[filtered2.length - 1].trim().startsWith("#")) {
507
+ filtered2.pop();
508
+ }
509
+ continue;
510
+ }
511
+ filtered2.push(lines[i]);
512
+ }
513
+ if (filtered2.length === lines.length) {
514
+ return {
515
+ content: [{ type: "text", text: `\u274C No cron job found matching: ${command}` }],
516
+ isError: true
517
+ };
518
+ }
519
+ const updated2 = filtered2.join("\n");
520
+ const tmpFile2 = `/tmp/junis_crontab_${Date.now()}.txt`;
521
+ await import_promises.default.writeFile(tmpFile2, updated2, "utf-8");
522
+ await execAsync(`crontab ${tmpFile2}`);
523
+ await import_promises.default.unlink(tmpFile2).catch(() => {
524
+ });
525
+ return { content: [{ type: "text", text: `\u2705 Deleted cron job matching: ${command}` }] };
526
+ }
527
+ const entries = [];
528
+ let idx = 1;
529
+ for (let i = 0; i < lines.length; i++) {
530
+ const line = lines[i].trim();
531
+ if (line.startsWith("#")) continue;
532
+ const parts = line.split(/\s+/);
533
+ if (parts.length >= 6) {
534
+ const prevIsComment = i > 0 && lines[i - 1].trim().startsWith("#");
535
+ entries.push({ lineStart: prevIsComment ? i - 1 : i, lineEnd: i, idx: idx++ });
536
+ }
537
+ }
538
+ const target = entries.find((e) => e.idx === id);
539
+ if (!target) {
540
+ return {
541
+ content: [{ type: "text", text: `\u274C No cron job found with id=${id}. Use cron_list to see current IDs.` }],
542
+ isError: true
543
+ };
544
+ }
545
+ const filtered = lines.filter((_, i) => i < target.lineStart || i > target.lineEnd);
546
+ const updated = filtered.join("\n");
547
+ const tmpFile = `/tmp/junis_crontab_${Date.now()}.txt`;
548
+ await import_promises.default.writeFile(tmpFile, updated, "utf-8");
549
+ await execAsync(`crontab ${tmpFile}`);
550
+ await import_promises.default.unlink(tmpFile).catch(() => {
551
+ });
552
+ return { content: [{ type: "text", text: `\u2705 Deleted cron job #${id}` }] };
553
+ } catch (err) {
554
+ return {
555
+ content: [{ type: "text", text: `\u274C Failed to delete cron job: ${err.message}` }],
556
+ isError: true
557
+ };
558
+ }
559
+ }
560
+ );
281
561
  }
282
562
  };
283
563
 
284
564
  // src/tools/browser.ts
285
- var import_playwright = require("playwright");
565
+ var import_browserclaw = require("browserclaw");
566
+ var import_promises2 = __toESM(require("fs/promises"));
286
567
  var import_zod2 = require("zod");
287
568
  var BrowserTools = class {
288
569
  browser = null;
289
570
  page = null;
290
- // 동시 요청 시 race condition 방지용 직렬화 락
291
571
  lock = Promise.resolve();
572
+ armedDialog = null;
292
573
  withLock(fn) {
293
574
  let release;
294
575
  const next = new Promise((r) => {
@@ -298,128 +579,403 @@ var BrowserTools = class {
298
579
  this.lock = this.lock.then(() => next);
299
580
  return current.then(() => fn()).finally(() => release());
300
581
  }
582
+ /** mcp.ts에서 호출하는 init — BrowserClaw는 browser_start 도구로 명시적 시작하므로 noop */
301
583
  async init() {
302
- try {
303
- this.browser = await import_playwright.chromium.launch({ headless: true });
304
- this.page = await this.browser.newPage();
305
- } catch {
306
- console.warn(
307
- "\u26A0\uFE0F Playwright \uBBF8\uC124\uCE58. \uBE0C\uB77C\uC6B0\uC800 \uB3C4\uAD6C \uBE44\uD65C\uC131\uD654.\n \uD65C\uC131\uD654: npx playwright install chromium"
308
- );
309
- }
310
584
  }
311
585
  async cleanup() {
312
- await this.browser?.close();
586
+ await this.browser?.stop();
587
+ this.browser = null;
588
+ this.page = null;
313
589
  }
314
590
  register(server) {
315
591
  const requirePage = () => {
316
- if (!this.page) throw new Error("\uBE0C\uB77C\uC6B0\uC800 \uBBF8\uCD08\uAE30\uD654. playwright \uC124\uCE58 \uD655\uC778.");
592
+ if (!this.page) throw new Error("Browser not started. Call browser_start first.");
317
593
  return this.page;
318
594
  };
595
+ server.tool(
596
+ "browser_start",
597
+ [
598
+ "Launch or connect to a web browser for automation.",
599
+ "",
600
+ "MODES:",
601
+ "- 'managed' (default): Launches a new Chromium instance. Use 'headless' for background operation, 'profile' for persistent sessions (cookies, logins preserved).",
602
+ "- 'remote-cdp': Connects to an already-running Chrome via CDP URL (e.g. from chrome://inspect). Use this to automate an existing browser session.",
603
+ "",
604
+ "WORKFLOW: browser_start \u2192 browser_navigate \u2192 browser_snapshot \u2192 interact (click/type/fill) \u2192 browser_stop.",
605
+ "Always call browser_stop when done to release system resources."
606
+ ].join("\n"),
607
+ {
608
+ mode: import_zod2.z.enum(["managed", "remote-cdp"]).optional().default("managed").describe("'managed' = launch new browser, 'remote-cdp' = connect to existing Chrome via CDP"),
609
+ headless: import_zod2.z.boolean().optional().default(false).describe("Run without visible window (managed mode only). Use for background tasks."),
610
+ cdpUrl: import_zod2.z.string().optional().describe("Chrome DevTools Protocol URL for remote-cdp mode (e.g. http://localhost:9222)"),
611
+ profile: import_zod2.z.string().optional().describe("Browser profile name for persistent sessions \u2014 preserves cookies, logins, and history across restarts (managed mode only)"),
612
+ allowInternal: import_zod2.z.boolean().optional().default(false).describe("Allow navigation to localhost and internal network URLs")
613
+ },
614
+ ({ mode, headless, cdpUrl, profile, allowInternal }) => this.withLock(async () => {
615
+ if (this.browser) {
616
+ return { content: [{ type: "text", text: "Browser is already running. Call browser_stop first." }] };
617
+ }
618
+ if (mode === "remote-cdp") {
619
+ if (!cdpUrl) throw new Error("cdpUrl is required for remote-cdp mode");
620
+ this.browser = await import_browserclaw.BrowserClaw.connect(cdpUrl, { allowInternal });
621
+ } else {
622
+ this.browser = await import_browserclaw.BrowserClaw.launch({
623
+ headless,
624
+ profileName: profile,
625
+ allowInternal
626
+ });
627
+ }
628
+ return { content: [{ type: "text", text: `Browser started (mode: ${mode})` }] };
629
+ })
630
+ );
631
+ server.tool(
632
+ "browser_stop",
633
+ "Stop the browser and release all associated resources (memory, connections, processes). Always call this when browser automation is complete.",
634
+ {},
635
+ () => this.withLock(async () => {
636
+ await this.cleanup();
637
+ return { content: [{ type: "text", text: "Browser stopped" }] };
638
+ })
639
+ );
319
640
  server.tool(
320
641
  "browser_navigate",
321
- "URL\uB85C \uC774\uB3D9",
322
- { url: import_zod2.z.string().describe("\uC774\uB3D9\uD560 URL") },
642
+ "Navigate the browser to a URL. Automatically opens a new tab if the browser is started but no page exists yet. Waits for the page to load before returning.",
643
+ {
644
+ url: import_zod2.z.string().describe("Full URL to navigate to (include https://)")
645
+ },
323
646
  ({ url }) => this.withLock(async () => {
324
- const page = requirePage();
325
- await page.goto(url, { waitUntil: "domcontentloaded" });
647
+ if (!this.browser) throw new Error("Browser not started. Call browser_start first.");
648
+ if (!this.page) {
649
+ this.page = await this.browser.open(url);
650
+ } else {
651
+ await this.page.goto(url);
652
+ }
653
+ const currentUrl = await this.page.url();
654
+ return { content: [{ type: "text", text: `Navigated to: ${currentUrl}` }] };
655
+ })
656
+ );
657
+ server.tool(
658
+ "browser_snapshot",
659
+ [
660
+ "Capture the page's Accessibility Tree with numbered ref IDs for each element. This is the primary way to 'see' and understand page content.",
661
+ "",
662
+ "WORKFLOW: Call browser_snapshot \u2192 find the target element's ref (e.g. 'e1', 'e5') \u2192 use that ref in browser_click, browser_type, or other interaction tools.",
663
+ "Refs change after page updates \u2014 always call browser_snapshot again after navigation or clicks that modify the page.",
664
+ "",
665
+ "Prefer this over browser_screenshot for understanding page structure \u2014 it's faster, structured, and machine-readable."
666
+ ].join("\n"),
667
+ {
668
+ interactive: import_zod2.z.boolean().optional().default(true).describe("true (default): only show clickable/typeable elements. false: show all elements including static text."),
669
+ compact: import_zod2.z.boolean().optional().default(true).describe("true (default): hide empty containers for cleaner output")
670
+ },
671
+ ({ interactive, compact }) => this.withLock(async () => {
672
+ const result = await requirePage().snapshot({ interactive, compact });
673
+ const { snapshot, refs, stats } = result;
674
+ const refList = Object.entries(refs).map(([r, info]) => ` ${r}: ${info.role} "${info.name ?? ""}"`).join("\n");
675
+ const total = stats?.refs ?? Object.keys(refs).length;
326
676
  return {
327
- content: [{ type: "text", text: `\uC774\uB3D9 \uC644\uB8CC: ${page.url()}` }]
677
+ content: [{
678
+ type: "text",
679
+ text: `${snapshot}
680
+
681
+ --- refs (${total} total) ---
682
+ ${refList}`
683
+ }]
328
684
  };
329
685
  })
330
686
  );
331
687
  server.tool(
332
688
  "browser_click",
333
- "\uC694\uC18C \uD074\uB9AD",
334
- { selector: import_zod2.z.string().describe("CSS \uC120\uD0DD\uC790") },
335
- ({ selector }) => this.withLock(async () => {
336
- await requirePage().click(selector);
337
- return { content: [{ type: "text", text: "\uD074\uB9AD \uC644\uB8CC" }] };
689
+ "Click an element by its ref number from browser_snapshot. Always call browser_snapshot first to get current refs \u2014 they change after page updates.",
690
+ {
691
+ ref: import_zod2.z.string().describe("Element ref from browser_snapshot (e.g. 'e1', 'e15'). Call browser_snapshot first to get current refs."),
692
+ doubleClick: import_zod2.z.boolean().optional().default(false).describe("Double-click instead of single click"),
693
+ button: import_zod2.z.enum(["left", "right", "middle"]).optional().default("left").describe("Mouse button to use")
694
+ },
695
+ ({ ref, doubleClick, button }) => this.withLock(async () => {
696
+ await requirePage().click(ref, { doubleClick, button });
697
+ return { content: [{ type: "text", text: `Clicked ref=${ref}` }] };
338
698
  })
339
699
  );
340
700
  server.tool(
341
701
  "browser_type",
342
- "\uD14D\uC2A4\uD2B8 \uC785\uB825",
702
+ "Type text into an input element by ref number. Use 'submit=true' to press Enter after typing (e.g. for search forms). Use 'slowly=true' for sites requiring keystroke-by-keystroke input.",
343
703
  {
344
- selector: import_zod2.z.string().describe("CSS \uC120\uD0DD\uC790"),
345
- text: import_zod2.z.string().describe("\uC785\uB825\uD560 \uD14D\uC2A4\uD2B8"),
346
- clear: import_zod2.z.boolean().optional().default(false).describe("\uAE30\uC874 \uB0B4\uC6A9 \uC0AD\uC81C \uD6C4 \uC785\uB825")
704
+ ref: import_zod2.z.string().describe("Element ref from browser_snapshot (e.g. 'e3')"),
705
+ text: import_zod2.z.string().describe("Text to type into the element"),
706
+ submit: import_zod2.z.boolean().optional().default(false).describe("Press Enter after typing (useful for search boxes and forms)"),
707
+ slowly: import_zod2.z.boolean().optional().default(false).describe("Type slowly (75ms per char) for sites that process each keystroke")
347
708
  },
348
- ({ selector, text, clear }) => this.withLock(async () => {
349
- const page = requirePage();
350
- if (clear) await page.fill(selector, text);
351
- else await page.type(selector, text);
352
- return { content: [{ type: "text", text: "\uC785\uB825 \uC644\uB8CC" }] };
709
+ ({ ref, text, submit, slowly }) => this.withLock(async () => {
710
+ await requirePage().type(ref, text, { submit, slowly });
711
+ return { content: [{ type: "text", text: `Typed into ref=${ref}` }] };
712
+ })
713
+ );
714
+ server.tool(
715
+ "browser_fill",
716
+ "Fill multiple form fields at once \u2014 more efficient than calling browser_type repeatedly. Each field needs a ref from browser_snapshot.",
717
+ {
718
+ fields: import_zod2.z.array(import_zod2.z.object({
719
+ ref: import_zod2.z.string(),
720
+ type: import_zod2.z.enum(["text", "checkbox", "radio"]),
721
+ value: import_zod2.z.union([import_zod2.z.string(), import_zod2.z.boolean()])
722
+ })).describe("Array of {ref, type, value}. type='text': value is string. type='checkbox'/'radio': value is boolean.")
723
+ },
724
+ ({ fields }) => this.withLock(async () => {
725
+ await requirePage().fill(fields);
726
+ return { content: [{ type: "text", text: `Filled ${fields.length} field(s)` }] };
727
+ })
728
+ );
729
+ server.tool(
730
+ "browser_select",
731
+ "Select one or more options from a dropdown/select element. Values should match the option value attributes, not display text.",
732
+ {
733
+ ref: import_zod2.z.string().describe("Ref of the <select> element from browser_snapshot"),
734
+ values: import_zod2.z.array(import_zod2.z.string()).describe("Option value(s) to select")
735
+ },
736
+ ({ ref, values }) => this.withLock(async () => {
737
+ await requirePage().select(ref, ...values);
738
+ return { content: [{ type: "text", text: `Selected option(s) in ref=${ref}` }] };
739
+ })
740
+ );
741
+ server.tool(
742
+ "browser_press",
743
+ "Press a keyboard key or key combination. Use for shortcuts (e.g. 'Control+a', 'Escape'), form submission ('Enter'), or navigation ('Tab'). Does not require a specific element ref.",
744
+ {
745
+ key: import_zod2.z.string().describe("Key or combination: 'Enter', 'Escape', 'Tab', 'Control+a', 'Meta+c', 'ArrowDown', 'Backspace'")
746
+ },
747
+ ({ key }) => this.withLock(async () => {
748
+ await requirePage().press(key);
749
+ return { content: [{ type: "text", text: `Pressed: ${key}` }] };
750
+ })
751
+ );
752
+ server.tool(
753
+ "browser_hover",
754
+ "Move the mouse cursor over an element by ref. Use to trigger hover menus, tooltips, or dropdown previews before clicking.",
755
+ {
756
+ ref: import_zod2.z.string().describe("Element ref from browser_snapshot")
757
+ },
758
+ ({ ref }) => this.withLock(async () => {
759
+ await requirePage().hover(ref);
760
+ return { content: [{ type: "text", text: `Hovered over ref=${ref}` }] };
761
+ })
762
+ );
763
+ server.tool(
764
+ "browser_drag",
765
+ "Drag an element from startRef to endRef. Both refs must come from a recent browser_snapshot. Use for drag-and-drop interfaces, sliders, or reorderable lists.",
766
+ {
767
+ startRef: import_zod2.z.string().describe("Source element ref to drag from"),
768
+ endRef: import_zod2.z.string().describe("Target element ref to drag to")
769
+ },
770
+ ({ startRef, endRef }) => this.withLock(async () => {
771
+ await requirePage().drag(startRef, endRef);
772
+ return { content: [{ type: "text", text: `Dragged ref=${startRef} \u2192 ref=${endRef}` }] };
773
+ })
774
+ );
775
+ server.tool(
776
+ "browser_upload",
777
+ "Upload local files to a file input element (<input type='file'>). The ref must point to a file input from browser_snapshot.",
778
+ {
779
+ ref: import_zod2.z.string().describe("Ref of the file input element from browser_snapshot"),
780
+ paths: import_zod2.z.array(import_zod2.z.string()).describe("Absolute file path(s) on the local device to upload")
781
+ },
782
+ ({ ref, paths }) => this.withLock(async () => {
783
+ await requirePage().uploadFile(ref, paths);
784
+ return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ref=${ref}` }] };
353
785
  })
354
786
  );
355
787
  server.tool(
356
788
  "browser_screenshot",
357
- "\uD604\uC7AC \uD398\uC774\uC9C0 \uC2A4\uD06C\uB9B0\uC0F7",
789
+ [
790
+ "Capture a screenshot of the current page. Returns base64 image data (viewable by AI) or saves to a file.",
791
+ "",
792
+ "Prefer browser_snapshot (Accessibility Tree) for understanding page structure \u2014 it's faster and machine-readable.",
793
+ "Use browser_screenshot only when visual layout matters (charts, images, styling, visual verification)."
794
+ ].join("\n"),
358
795
  {
359
- path: import_zod2.z.string().optional().describe("\uC800\uC7A5 \uACBD\uB85C (\uC5C6\uC73C\uBA74 base64 \uBC18\uD658)"),
360
- full_page: import_zod2.z.boolean().optional().default(false)
796
+ path: import_zod2.z.string().optional().describe("Save path for the screenshot. If omitted, returns base64 image data directly."),
797
+ fullPage: import_zod2.z.boolean().optional().default(false).describe("Capture the full scrollable page, not just the visible viewport"),
798
+ ref: import_zod2.z.string().optional().describe("Capture only a specific element by its ref from browser_snapshot")
361
799
  },
362
- ({ path: path2, full_page }) => this.withLock(async () => {
363
- const page = requirePage();
364
- const screenshot = await page.screenshot({
365
- path: path2 ?? void 0,
366
- fullPage: full_page
367
- });
800
+ ({ path: path2, fullPage, ref }) => this.withLock(async () => {
801
+ const buffer = await requirePage().screenshot({ fullPage, ref });
368
802
  if (path2) {
369
- return { content: [{ type: "text", text: `\uC800\uC7A5 \uC644\uB8CC: ${path2}` }] };
803
+ await import_promises2.default.writeFile(path2, buffer);
804
+ return { content: [{ type: "text", text: `Screenshot saved: ${path2}` }] };
370
805
  }
371
806
  return {
372
- content: [
373
- {
374
- type: "image",
375
- data: screenshot.toString("base64"),
376
- mimeType: "image/png"
377
- }
378
- ]
807
+ content: [{
808
+ type: "image",
809
+ data: buffer.toString("base64"),
810
+ mimeType: "image/png"
811
+ }]
379
812
  };
380
813
  })
381
814
  );
382
815
  server.tool(
383
- "browser_snapshot",
384
- "\uD398\uC774\uC9C0 \uC811\uADFC\uC131 \uD2B8\uB9AC \uC870\uD68C (\uAD6C\uC870 \uD30C\uC545\uC6A9)",
385
- {},
386
- () => this.withLock(async () => {
387
- const page = requirePage();
388
- const snapshot = await page.locator("body").ariaSnapshot();
389
- return {
390
- content: [
391
- { type: "text", text: snapshot }
392
- ]
393
- };
816
+ "browser_pdf",
817
+ "Save the current page as a PDF file. Renders the full page including below-the-fold content. Useful for archiving, sharing, or offline reading.",
818
+ {
819
+ path: import_zod2.z.string().describe("Output file path (.pdf)")
820
+ },
821
+ ({ path: path2 }) => this.withLock(async () => {
822
+ const buffer = await requirePage().pdf();
823
+ await import_promises2.default.writeFile(path2, buffer);
824
+ return { content: [{ type: "text", text: `PDF saved: ${path2}` }] };
394
825
  })
395
826
  );
396
827
  server.tool(
397
828
  "browser_evaluate",
398
- "JavaScript \uC2E4\uD589",
399
- { code: import_zod2.z.string().describe("\uC2E4\uD589\uD560 JavaScript \uCF54\uB4DC") },
829
+ [
830
+ "Execute JavaScript code directly in the browser page context and return the result.",
831
+ "",
832
+ "Use for: extracting data not available in the Accessibility Tree, DOM manipulation, interacting with page APIs, or debugging.",
833
+ "Wrap complex logic in an IIFE: (function(){ ... })()"
834
+ ].join("\n"),
835
+ {
836
+ code: import_zod2.z.string().describe("JavaScript code to execute in the page context. Return values are automatically serialized.")
837
+ },
400
838
  ({ code }) => this.withLock(async () => {
401
839
  try {
402
840
  const result = await requirePage().evaluate(code);
403
841
  return {
404
- content: [
405
- { type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }
406
- ]
842
+ content: [{
843
+ type: "text",
844
+ text: typeof result === "string" ? result : JSON.stringify(result, null, 2)
845
+ }]
407
846
  };
408
847
  } catch (err) {
409
848
  return {
410
- content: [{ type: "text", text: `\u274C JavaScript \uC2E4\uD589 \uC624\uB958: ${err.message}` }],
849
+ content: [{ type: "text", text: `\u274C JS error: ${err.message}` }],
411
850
  isError: true
412
851
  };
413
852
  }
414
853
  })
415
854
  );
416
855
  server.tool(
417
- "browser_pdf",
418
- "\uD604\uC7AC \uD398\uC774\uC9C0 PDF \uC800\uC7A5",
419
- { path: import_zod2.z.string().describe("\uC800\uC7A5 \uACBD\uB85C (.pdf)") },
420
- ({ path: path2 }) => this.withLock(async () => {
421
- await requirePage().pdf({ path: path2 });
422
- return { content: [{ type: "text", text: `PDF \uC800\uC7A5 \uC644\uB8CC: ${path2}` }] };
856
+ "browser_wait",
857
+ [
858
+ "Wait for a specific condition before proceeding. Use between actions when the page needs time to update.",
859
+ "",
860
+ "OPTIONS (use one): 'text' (wait for text to appear), 'textGone' (wait for text to disappear), 'url' (URL matches glob), 'loadState' (page load state), 'timeMs' (fixed delay as last resort)."
861
+ ].join("\n"),
862
+ {
863
+ text: import_zod2.z.string().optional().describe("Wait until this text appears on the page"),
864
+ textGone: import_zod2.z.string().optional().describe("Wait until this text disappears from the page"),
865
+ url: import_zod2.z.string().optional().describe("Wait until URL matches this glob pattern (e.g. '**/dashboard', '**/success')"),
866
+ loadState: import_zod2.z.enum(["load", "domcontentloaded", "networkidle"]).optional().describe("Wait for page load state: 'load' (full), 'domcontentloaded' (DOM ready), 'networkidle' (no pending requests)"),
867
+ timeMs: import_zod2.z.number().optional().describe("Fixed wait in milliseconds \u2014 use as last resort when other conditions don't apply")
868
+ },
869
+ ({ text, textGone, url, loadState, timeMs }) => this.withLock(async () => {
870
+ const condition = {};
871
+ if (text) condition.text = text;
872
+ if (textGone) condition.textGone = textGone;
873
+ if (url) condition.url = url;
874
+ if (loadState) condition.loadState = loadState;
875
+ if (timeMs) condition.timeMs = timeMs;
876
+ await requirePage().waitFor(condition);
877
+ return { content: [{ type: "text", text: "Wait condition met" }] };
878
+ })
879
+ );
880
+ server.tool(
881
+ "browser_cookies",
882
+ "Manage browser cookies: get all cookies, set a specific cookie, or clear all cookies. Useful for authentication state, session management, or testing.",
883
+ {
884
+ action: import_zod2.z.enum(["get", "set", "clear"]).describe("'get': retrieve all cookies, 'set': add/update a cookie, 'clear': remove all cookies"),
885
+ cookie: import_zod2.z.object({
886
+ name: import_zod2.z.string(),
887
+ value: import_zod2.z.string(),
888
+ domain: import_zod2.z.string().optional(),
889
+ path: import_zod2.z.string().optional(),
890
+ httpOnly: import_zod2.z.boolean().optional(),
891
+ secure: import_zod2.z.boolean().optional()
892
+ }).optional().describe("Cookie data (required for 'set' action)")
893
+ },
894
+ ({ action, cookie }) => this.withLock(async () => {
895
+ const page = requirePage();
896
+ if (action === "get") {
897
+ const cookies = await page.cookies();
898
+ return { content: [{ type: "text", text: JSON.stringify(cookies, null, 2) }] };
899
+ } else if (action === "set") {
900
+ if (!cookie) throw new Error("cookie is required for set action");
901
+ await page.setCookie({ path: "/", ...cookie });
902
+ return { content: [{ type: "text", text: `Cookie set: ${cookie.name}` }] };
903
+ } else {
904
+ await page.clearCookies();
905
+ return { content: [{ type: "text", text: "All cookies cleared" }] };
906
+ }
907
+ })
908
+ );
909
+ server.tool(
910
+ "browser_storage",
911
+ "Read, write, or clear browser localStorage/sessionStorage. Useful for managing client-side state, authentication tokens, or application preferences.",
912
+ {
913
+ action: import_zod2.z.enum(["get", "set", "clear"]).describe("'get': read value(s), 'set': write a key-value pair, 'clear': remove all entries"),
914
+ kind: import_zod2.z.enum(["local", "session"]).optional().default("local").describe("'local' (persistent) or 'session' (cleared on tab close)"),
915
+ key: import_zod2.z.string().optional().describe("Storage key to get or set. Omit key with 'get' to retrieve all entries."),
916
+ value: import_zod2.z.string().optional().describe("Value to store (required for 'set' action)")
917
+ },
918
+ ({ action, kind, key, value }) => this.withLock(async () => {
919
+ const page = requirePage();
920
+ if (action === "get") {
921
+ const result = await page.storageGet(kind, key);
922
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
923
+ } else if (action === "set") {
924
+ if (!key || value === void 0) throw new Error("key and value are required for set action");
925
+ await page.storageSet(kind, key, value);
926
+ return { content: [{ type: "text", text: `Storage set: ${key}` }] };
927
+ } else {
928
+ await page.storageClear(kind);
929
+ return { content: [{ type: "text", text: `${kind}Storage cleared` }] };
930
+ }
931
+ })
932
+ );
933
+ server.tool(
934
+ "browser_dialog",
935
+ [
936
+ "Handle JavaScript dialogs (alert, confirm, prompt). Two-step pattern:",
937
+ " 1. action='arm' \u2014 register a one-shot handler (returns immediately, does NOT block).",
938
+ " 2. Trigger the dialog (e.g. browser_click on the button that calls confirm()).",
939
+ " 3. action='wait' \u2014 await the handler to confirm the dialog was handled.",
940
+ "",
941
+ "The 'accept' and 'promptText' params are only used with action='arm'."
942
+ ].join("\n"),
943
+ {
944
+ action: import_zod2.z.enum(["arm", "wait"]).describe(
945
+ "'arm' = register handler and return immediately; 'wait' = await the previously armed handler"
946
+ ),
947
+ accept: import_zod2.z.boolean().optional().default(true).describe(
948
+ "Accept (true) or dismiss (false) the dialog. Only used with action='arm'."
949
+ ),
950
+ promptText: import_zod2.z.string().optional().describe(
951
+ "Text to enter if the dialog is a prompt. Only used with action='arm'."
952
+ ),
953
+ timeoutMs: import_zod2.z.number().optional().describe(
954
+ "Timeout in ms for 'wait' action (default: 30000). Increase for slow-loading dialogs."
955
+ )
956
+ },
957
+ ({ action, accept, promptText, timeoutMs }) => this.withLock(async () => {
958
+ if (action === "arm") {
959
+ this.armedDialog = requirePage().armDialog({
960
+ accept: accept ?? true,
961
+ promptText,
962
+ timeoutMs
963
+ });
964
+ this.armedDialog.catch(() => {
965
+ });
966
+ return { content: [{ type: "text", text: "Dialog handler armed. Trigger the dialog now, then call browser_dialog with action='wait'." }] };
967
+ } else {
968
+ if (!this.armedDialog) {
969
+ return {
970
+ content: [{ type: "text", text: "No dialog handler is armed. Call browser_dialog with action='arm' first." }],
971
+ isError: true
972
+ };
973
+ }
974
+ const pending = this.armedDialog;
975
+ this.armedDialog = null;
976
+ await pending;
977
+ return { content: [{ type: "text", text: "Dialog handled successfully." }] };
978
+ }
423
979
  })
424
980
  );
425
981
  }
@@ -427,33 +983,33 @@ var BrowserTools = class {
427
983
 
428
984
  // src/tools/notebook.ts
429
985
  var import_zod3 = require("zod");
430
- var import_promises2 = __toESM(require("fs/promises"));
986
+ var import_promises3 = __toESM(require("fs/promises"));
431
987
  var import_child_process2 = require("child_process");
432
988
  var import_util2 = require("util");
433
989
  var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
434
990
  async function readNotebook(filePath) {
435
- const raw = await import_promises2.default.readFile(filePath, "utf-8");
991
+ const raw = await import_promises3.default.readFile(filePath, "utf-8");
436
992
  try {
437
993
  return JSON.parse(raw);
438
994
  } catch {
439
- throw new Error(`\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 Jupyter \uB178\uD2B8\uBD81 \uD30C\uC77C\uC785\uB2C8\uB2E4: ${filePath}`);
995
+ throw new Error(`Invalid Jupyter notebook file: ${filePath}`);
440
996
  }
441
997
  }
442
998
  async function writeNotebook(filePath, nb) {
443
- await import_promises2.default.writeFile(filePath, JSON.stringify(nb, null, 1), "utf-8");
999
+ await import_promises3.default.writeFile(filePath, JSON.stringify(nb, null, 1), "utf-8");
444
1000
  }
445
1001
  var NotebookTools = class {
446
1002
  register(server) {
447
1003
  server.tool(
448
1004
  "notebook_read",
449
- ".ipynb \uB178\uD2B8\uBD81 \uC77D\uAE30",
450
- { path: import_zod3.z.string().describe("\uB178\uD2B8\uBD81 \uD30C\uC77C \uACBD\uB85C") },
1005
+ "Read a Jupyter notebook (.ipynb) and return all cells with their types (code/markdown), source content, and output counts. Use this to understand notebook structure before making edits.",
1006
+ { path: import_zod3.z.string().describe("Path to the .ipynb notebook file") },
451
1007
  async ({ path: filePath }) => {
452
1008
  const nb = await readNotebook(filePath);
453
1009
  const cells = nb.cells.map((cell, i) => ({
454
1010
  index: i,
455
1011
  type: cell.cell_type,
456
- source: cell.source.join(""),
1012
+ source: Array.isArray(cell.source) ? cell.source.join("") : cell.source,
457
1013
  outputs: cell.outputs?.length ?? 0
458
1014
  }));
459
1015
  return {
@@ -463,30 +1019,35 @@ var NotebookTools = class {
463
1019
  );
464
1020
  server.tool(
465
1021
  "notebook_edit_cell",
466
- "\uB178\uD2B8\uBD81 \uD2B9\uC815 \uC140 \uC218\uC815",
1022
+ "Replace the source code of a specific cell in a Jupyter notebook. Use notebook_read first to identify the correct cell index (0-based). Existing outputs for the cell are preserved \u2014 use notebook_execute to re-run.",
467
1023
  {
468
- path: import_zod3.z.string(),
469
- cell_index: import_zod3.z.number().describe("0\uBD80\uD130 \uC2DC\uC791\uD558\uB294 \uC140 \uC778\uB371\uC2A4"),
470
- source: import_zod3.z.string().describe("\uC0C8 \uC18C\uC2A4 \uCF54\uB4DC")
1024
+ path: import_zod3.z.string().describe("Path to the .ipynb notebook file"),
1025
+ cell_index: import_zod3.z.number().describe("Cell index to edit (0-based). Use notebook_read to find the right index."),
1026
+ source: import_zod3.z.string().describe("New source code/content for the cell (replaces entire cell content)")
471
1027
  },
472
1028
  async ({ path: filePath, cell_index, source }) => {
473
1029
  const nb = await readNotebook(filePath);
474
1030
  if (cell_index < 0 || cell_index >= nb.cells.length) {
475
- throw new Error(`\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 \uC140 \uC778\uB371\uC2A4: ${cell_index}`);
1031
+ throw new Error(`Invalid cell index: ${cell_index}`);
476
1032
  }
477
1033
  nb.cells[cell_index].source = source.split("\n").map(
478
1034
  (l, i, arr) => i < arr.length - 1 ? l + "\n" : l
479
1035
  );
480
1036
  await writeNotebook(filePath, nb);
481
- return { content: [{ type: "text", text: "\uC140 \uC218\uC815 \uC644\uB8CC" }] };
1037
+ return { content: [{ type: "text", text: "Cell updated" }] };
482
1038
  }
483
1039
  );
484
1040
  server.tool(
485
1041
  "notebook_execute",
486
- "\uB178\uD2B8\uBD81 \uC2E4\uD589 (nbconvert --execute)",
1042
+ [
1043
+ "Execute all cells in a Jupyter notebook using nbconvert. Results are saved in-place \u2014 the notebook file is updated with execution outputs.",
1044
+ "",
1045
+ "Requires Jupyter to be installed (pip install jupyter). The timeout applies per cell, not for the entire notebook.",
1046
+ "If execution fails on a cell, the error is captured in the cell output and subsequent cells may not execute."
1047
+ ].join("\n"),
487
1048
  {
488
- path: import_zod3.z.string().describe("\uB178\uD2B8\uBD81 \uD30C\uC77C \uACBD\uB85C"),
489
- timeout: import_zod3.z.number().optional().default(300).describe("\uC140\uB2F9 \uD0C0\uC784\uC544\uC6C3 (\uCD08)")
1049
+ path: import_zod3.z.string().describe("Path to the .ipynb notebook file to execute"),
1050
+ timeout: import_zod3.z.number().optional().default(300).describe("Maximum execution time per cell in seconds (default: 300). Increase for cells with heavy computation.")
490
1051
  },
491
1052
  async ({ path: filePath, timeout }) => {
492
1053
  const nbconvertArgs = `nbconvert --to notebook --execute --inplace "${filePath}" --ExecutePreprocessor.timeout=${timeout}`;
@@ -502,7 +1063,7 @@ var NotebookTools = class {
502
1063
  for (const jupyter of candidates) {
503
1064
  try {
504
1065
  const { stdout, stderr } = await execAsync2(`${jupyter} ${nbconvertArgs}`);
505
- return { content: [{ type: "text", text: stdout || stderr || "\uC2E4\uD589 \uC644\uB8CC" }] };
1066
+ return { content: [{ type: "text", text: stdout || stderr || "Execution complete" }] };
506
1067
  } catch (err) {
507
1068
  const error = err;
508
1069
  if (error.code !== "127" && !error.message?.includes("not found") && !error.message?.includes("No such file")) {
@@ -510,17 +1071,17 @@ var NotebookTools = class {
510
1071
  }
511
1072
  }
512
1073
  }
513
- throw new Error("jupyter\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uC124\uCE58 \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694: pip install jupyter");
1074
+ throw new Error("jupyter not found. Install it and try again: pip install jupyter");
514
1075
  }
515
1076
  );
516
1077
  server.tool(
517
1078
  "notebook_add_cell",
518
- "\uB178\uD2B8\uBD81\uC5D0 \uC0C8 \uC140 \uCD94\uAC00",
1079
+ "Insert a new cell into a Jupyter notebook. If position is omitted, the cell is appended at the end. Use cell_type='code' for executable Python cells, 'markdown' for documentation/text cells.",
519
1080
  {
520
- path: import_zod3.z.string().describe(".ipynb \uD30C\uC77C \uACBD\uB85C"),
521
- cell_type: import_zod3.z.enum(["code", "markdown"]).describe("\uC140 \uD0C0\uC785"),
522
- source: import_zod3.z.string().describe("\uC140 \uC18C\uC2A4 \uB0B4\uC6A9"),
523
- position: import_zod3.z.number().optional().describe("\uC0BD\uC785 \uC704\uCE58(0-based). \uC5C6\uC73C\uBA74 \uB9E8 \uB05D\uC5D0 \uCD94\uAC00")
1081
+ path: import_zod3.z.string().describe("Path to the .ipynb notebook file"),
1082
+ cell_type: import_zod3.z.enum(["code", "markdown"]).describe("'code' for executable cells, 'markdown' for text/documentation cells"),
1083
+ source: import_zod3.z.string().describe("Cell source content (Python code or Markdown text)"),
1084
+ position: import_zod3.z.number().optional().describe("Insert position (0-based index). Omit to append at the end. If position exceeds cell count, appends at end with a warning.")
524
1085
  },
525
1086
  async ({ path: filePath, cell_type: cellType, source, position }) => {
526
1087
  const nb = await readNotebook(filePath);
@@ -539,31 +1100,31 @@ var NotebookTools = class {
539
1100
  } else if (position > nb.cells.length) {
540
1101
  nb.cells.push(newCell);
541
1102
  actualIndex = nb.cells.length - 1;
542
- warning = ` (\uACBD\uACE0: position ${position}\uC774 \uBC94\uC704\uB97C \uCD08\uACFC\uD558\uC5EC \uB05D(index: ${actualIndex})\uC5D0 \uCD94\uAC00\uB428)`;
1103
+ warning = ` (warning: position ${position} exceeded range, appended at end (index: ${actualIndex}))`;
543
1104
  } else {
544
1105
  const clamped = Math.max(0, position);
545
1106
  nb.cells.splice(clamped, 0, newCell);
546
1107
  actualIndex = clamped;
547
1108
  }
548
1109
  await writeNotebook(filePath, nb);
549
- return { content: [{ type: "text", text: `\uC140 \uCD94\uAC00 \uC644\uB8CC (index: ${actualIndex})${warning}` }] };
1110
+ return { content: [{ type: "text", text: `Cell added (index: ${actualIndex})${warning}` }] };
550
1111
  }
551
1112
  );
552
1113
  server.tool(
553
1114
  "notebook_delete_cell",
554
- "\uB178\uD2B8\uBD81 \uD2B9\uC815 \uC140 \uC0AD\uC81C",
1115
+ "Delete a cell from a Jupyter notebook by its 0-based index. Use notebook_read first to verify the cell content before deletion. This action cannot be undone.",
555
1116
  {
556
- path: import_zod3.z.string().describe(".ipynb \uD30C\uC77C \uACBD\uB85C"),
557
- cell_index: import_zod3.z.number().describe("\uC0AD\uC81C\uD560 \uC140 \uC778\uB371\uC2A4 (0-based)")
1117
+ path: import_zod3.z.string().describe("Path to the .ipynb notebook file"),
1118
+ cell_index: import_zod3.z.number().describe("Cell index to delete (0-based). Use notebook_read first to verify content.")
558
1119
  },
559
1120
  async ({ path: filePath, cell_index }) => {
560
1121
  const nb = await readNotebook(filePath);
561
1122
  if (cell_index < 0 || cell_index >= nb.cells.length) {
562
- throw new Error(`\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 \uC140 \uC778\uB371\uC2A4: ${cell_index}`);
1123
+ throw new Error(`Invalid cell index: ${cell_index}`);
563
1124
  }
564
1125
  nb.cells.splice(cell_index, 1);
565
1126
  await writeNotebook(filePath, nb);
566
- return { content: [{ type: "text", text: `\uC140 \uC0AD\uC81C \uC644\uB8CC (index: ${cell_index})` }] };
1127
+ return { content: [{ type: "text", text: `Cell deleted (index: ${cell_index})` }] };
567
1128
  }
568
1129
  );
569
1130
  }
@@ -583,44 +1144,16 @@ function platform() {
583
1144
  }
584
1145
  var DeviceTools = class {
585
1146
  register(server) {
586
- server.tool(
587
- "screen_capture",
588
- "\uD654\uBA74 \uC2A4\uD06C\uB9B0\uC0F7 (OS \uB124\uC774\uD2F0\uBE0C)",
589
- {
590
- output_path: import_zod4.z.string().optional().describe("\uC800\uC7A5 \uACBD\uB85C (\uC5C6\uC73C\uBA74 temp \uC800\uC7A5 \uD6C4 base64 \uBC18\uD658)")
591
- },
592
- async ({ output_path }) => {
593
- const p = platform();
594
- const isTmp = !output_path;
595
- const tmpPath = output_path ?? `/tmp/junis_screen_${Date.now()}.png`;
596
- const cmd = {
597
- mac: `screencapture -x "${tmpPath}"`,
598
- win: `nircmd.exe savescreenshot "${tmpPath}"`,
599
- linux: `scrot "${tmpPath}"`
600
- }[p];
601
- try {
602
- await execAsync3(cmd);
603
- } catch (err) {
604
- throw new Error(`\uD654\uBA74 \uCEA1\uCC98 \uC2E4\uD328: ${err.message}`);
605
- }
606
- const { readFileSync, unlinkSync } = await import("fs");
607
- const data = readFileSync(tmpPath).toString("base64");
608
- if (isTmp) {
609
- try {
610
- unlinkSync(tmpPath);
611
- } catch {
612
- }
613
- }
614
- return {
615
- content: [{ type: "image", data, mimeType: "image/png" }]
616
- };
617
- }
618
- );
619
1147
  server.tool(
620
1148
  "camera_capture",
621
- "\uCE74\uBA54\uB77C \uC0AC\uC9C4 \uCD2C\uC601",
1149
+ [
1150
+ "Capture a photo from the device's camera and return it as base64 image data.",
1151
+ "",
1152
+ "Platform-specific: macOS (imagesnap), Windows (ffmpeg/dshow), Linux (fswebcam).",
1153
+ "Requires a connected camera with OS permissions granted. If output_path is provided, the file is also saved to disk."
1154
+ ].join("\n"),
622
1155
  {
623
- output_path: import_zod4.z.string().optional()
1156
+ output_path: import_zod4.z.string().optional().describe("File path to save the captured photo. If omitted, returns image data only (temp file auto-cleaned).")
624
1157
  },
625
1158
  async ({ output_path }) => {
626
1159
  const p = platform();
@@ -636,10 +1169,10 @@ var DeviceTools = class {
636
1169
  } catch (err) {
637
1170
  const e = err;
638
1171
  return {
639
- content: [{ type: "text", text: `\u274C \uCE74\uBA54\uB77C\uB97C \uCC3E\uC744 \uC218 \uC5C6\uAC70\uB098 \uC811\uADFC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.
640
- \uC6D0\uC778: ${e.message}
1172
+ content: [{ type: "text", text: `\u274C Camera not found or inaccessible.
1173
+ Cause: ${e.message}
641
1174
 
642
- \uCE74\uBA54\uB77C\uAC00 \uC5F0\uACB0\uB418\uC5B4 \uC788\uB294\uC9C0 \uD655\uC778\uD558\uC138\uC694.` }],
1175
+ Please check if a camera is connected.` }],
643
1176
  isError: true
644
1177
  };
645
1178
  }
@@ -656,10 +1189,10 @@ var DeviceTools = class {
656
1189
  );
657
1190
  server.tool(
658
1191
  "notification_send",
659
- "OS \uC54C\uB9BC \uC804\uC1A1",
1192
+ "Send a native OS notification (banner/toast) to the user's desktop. Use for task completion alerts, reminders, or important status updates. The notification appears even when the terminal is not focused.",
660
1193
  {
661
- title: import_zod4.z.string().describe("\uC54C\uB9BC \uC81C\uBAA9"),
662
- message: import_zod4.z.string().describe("\uC54C\uB9BC \uB0B4\uC6A9")
1194
+ title: import_zod4.z.string().describe("Notification title (displayed prominently)"),
1195
+ message: import_zod4.z.string().describe("Notification body text")
663
1196
  },
664
1197
  async ({ title, message }) => {
665
1198
  try {
@@ -672,10 +1205,10 @@ var DeviceTools = class {
672
1205
  }
673
1206
  );
674
1207
  });
675
- return { content: [{ type: "text", text: "\uC54C\uB9BC \uC804\uC1A1 \uC644\uB8CC" }] };
1208
+ return { content: [{ type: "text", text: "Notification sent" }] };
676
1209
  } catch (err) {
677
1210
  return {
678
- content: [{ type: "text", text: `\uC54C\uB9BC \uC804\uC1A1 \uC2E4\uD328: ${err.message}` }],
1211
+ content: [{ type: "text", text: `Notification failed: ${err.message}` }],
679
1212
  isError: true
680
1213
  };
681
1214
  }
@@ -683,7 +1216,7 @@ var DeviceTools = class {
683
1216
  );
684
1217
  server.tool(
685
1218
  "clipboard_read",
686
- "\uD074\uB9BD\uBCF4\uB4DC \uC77D\uAE30",
1219
+ "Read the current contents of the system clipboard (text). Use to access content the user has copied. Platform-specific: macOS (pbpaste), Windows (PowerShell), Linux (xclip).",
687
1220
  {},
688
1221
  async () => {
689
1222
  const p = platform();
@@ -694,8 +1227,10 @@ var DeviceTools = class {
694
1227
  );
695
1228
  server.tool(
696
1229
  "clipboard_write",
697
- "\uD074\uB9BD\uBCF4\uB4DC \uC4F0\uAE30",
698
- { text: import_zod4.z.string() },
1230
+ "Write text to the system clipboard, replacing its current contents. Use to prepare content for the user to paste elsewhere.",
1231
+ {
1232
+ text: import_zod4.z.string().describe("Text to copy to the clipboard")
1233
+ },
699
1234
  async ({ text }) => {
700
1235
  const p = platform();
701
1236
  const cmd = {
@@ -704,21 +1239,26 @@ var DeviceTools = class {
704
1239
  linux: `echo "${text}" | xclip -selection clipboard`
705
1240
  }[p];
706
1241
  await execAsync3(cmd);
707
- return { content: [{ type: "text", text: "\uD074\uB9BD\uBCF4\uB4DC \uC800\uC7A5 \uC644\uB8CC" }] };
1242
+ return { content: [{ type: "text", text: "Saved to clipboard" }] };
708
1243
  }
709
1244
  );
710
1245
  server.tool(
711
1246
  "screen_record",
712
- "\uD654\uBA74 \uB179\uD654 \uC2DC\uC791/\uC911\uC9C0 (macOS: screencapture -v, \uAE30\uD0C0: ffmpeg)",
1247
+ [
1248
+ "Start or stop screen recording. Captures the full screen as MP4 video.",
1249
+ "",
1250
+ "Use action='start' to begin, action='stop' to end and save. Only one recording can be active at a time.",
1251
+ "Platform-specific: macOS (screencapture -v), Windows/Linux (ffmpeg)."
1252
+ ].join("\n"),
713
1253
  {
714
- action: import_zod4.z.enum(["start", "stop"]).describe("start: \uB179\uD654 \uC2DC\uC791, stop: \uB179\uD654 \uC911\uC9C0"),
715
- output_path: import_zod4.z.string().optional().describe("\uC800\uC7A5 \uACBD\uB85C (start \uC2DC \uC0AC\uC6A9, \uAE30\uBCF8: /tmp/junis_record_<timestamp>.mp4)")
1254
+ action: import_zod4.z.enum(["start", "stop"]).describe("'start': begin recording, 'stop': end recording and save the file"),
1255
+ output_path: import_zod4.z.string().optional().describe("Output file path (used with 'start'). Default: /tmp/junis_record_<timestamp>.mp4")
716
1256
  },
717
1257
  async ({ action, output_path }) => {
718
1258
  const p = platform();
719
1259
  if (action === "start") {
720
1260
  if (screenRecordPid) {
721
- return { content: [{ type: "text", text: "\uC774\uBBF8 \uB179\uD654 \uC911\uC785\uB2C8\uB2E4." }] };
1261
+ return { content: [{ type: "text", text: "Already recording." }] };
722
1262
  }
723
1263
  const tmpPath = output_path ?? `/tmp/junis_record_${Date.now()}.mp4`;
724
1264
  const { spawn } = await import("child_process");
@@ -726,10 +1266,10 @@ var DeviceTools = class {
726
1266
  const child = spawn(cmd[0], cmd[1], { detached: true, stdio: "ignore" });
727
1267
  child.unref();
728
1268
  screenRecordPid = child.pid ?? null;
729
- return { content: [{ type: "text", text: `\uB179\uD654 \uC2DC\uC791\uB428. \uC800\uC7A5 \uACBD\uB85C: ${tmpPath} (PID: ${screenRecordPid})` }] };
1269
+ return { content: [{ type: "text", text: `Recording started. Output path: ${tmpPath} (PID: ${screenRecordPid})` }] };
730
1270
  } else {
731
1271
  if (!screenRecordPid) {
732
- return { content: [{ type: "text", text: "\uD604\uC7AC \uB179\uD654 \uC911\uC774 \uC544\uB2D9\uB2C8\uB2E4." }] };
1272
+ return { content: [{ type: "text", text: "Not currently recording." }] };
733
1273
  }
734
1274
  try {
735
1275
  process.kill(screenRecordPid, "SIGINT");
@@ -737,13 +1277,18 @@ var DeviceTools = class {
737
1277
  } catch {
738
1278
  }
739
1279
  screenRecordPid = null;
740
- return { content: [{ type: "text", text: "\uB179\uD654 \uC911\uC9C0\uB428." }] };
1280
+ return { content: [{ type: "text", text: "Recording stopped." }] };
741
1281
  }
742
1282
  }
743
1283
  );
744
1284
  server.tool(
745
1285
  "location_get",
746
- "\uD604\uC7AC \uC704\uCE58 \uC870\uD68C (macOS: CoreLocation CLI, \uAE30\uD0C0: IP \uAE30\uBC18 fallback)",
1286
+ [
1287
+ "Get the device's current geographic location.",
1288
+ "",
1289
+ "macOS: Uses CoreLocation (GPS-accurate) with IP-based fallback. Other platforms: IP-based geolocation (city-level accuracy only).",
1290
+ "Returns latitude, longitude, and (when available) city and country."
1291
+ ].join("\n"),
747
1292
  {},
748
1293
  async () => {
749
1294
  const p = platform();
@@ -751,28 +1296,28 @@ var DeviceTools = class {
751
1296
  try {
752
1297
  const { stdout } = await execAsync3("CoreLocationCLI -once -format '%latitude,%longitude'", { timeout: 1e4 });
753
1298
  const [lat, lon] = stdout.trim().split(",");
754
- return { content: [{ type: "text", text: `\uC704\uB3C4: ${lat}, \uACBD\uB3C4: ${lon}` }] };
1299
+ return { content: [{ type: "text", text: `Latitude: ${lat}, Longitude: ${lon}` }] };
755
1300
  } catch {
756
1301
  }
757
1302
  }
758
1303
  const res = await fetch("http://ip-api.com/json/");
759
1304
  const data = await res.json();
760
1305
  if (data.status !== "success") {
761
- throw new Error(`IP \uC704\uCE58 \uC870\uD68C \uC2E4\uD328: ${data.message ?? data.status}`);
1306
+ throw new Error(`IP location lookup failed: ${data.message ?? data.status}`);
762
1307
  }
763
1308
  return {
764
1309
  content: [{
765
1310
  type: "text",
766
- text: `\uC704\uB3C4: ${data.lat}, \uACBD\uB3C4: ${data.lon}, \uB3C4\uC2DC: ${data.city}, \uAD6D\uAC00: ${data.country} (IP \uAE30\uBC18 \uCD94\uC815)`
1311
+ text: `Latitude: ${data.lat}, Longitude: ${data.lon}, City: ${data.city}, Country: ${data.country} (estimated via IP)`
767
1312
  }]
768
1313
  };
769
1314
  }
770
1315
  );
771
1316
  server.tool(
772
1317
  "audio_play",
773
- "\uC624\uB514\uC624 \uD30C\uC77C \uC7AC\uC0DD (macOS: afplay, \uAE30\uD0C0: ffplay)",
1318
+ "Play an audio file through the device's speakers. Supports MP3, WAV, AAC, and other common formats. Playback is synchronous \u2014 the tool returns after playback completes. Platform-specific: macOS (afplay), Windows/Linux (ffplay).",
774
1319
  {
775
- file_path: import_zod4.z.string().describe("\uC7AC\uC0DD\uD560 \uC624\uB514\uC624 \uD30C\uC77C \uACBD\uB85C")
1320
+ file_path: import_zod4.z.string().describe("Absolute path to the audio file to play")
776
1321
  },
777
1322
  async ({ file_path }) => {
778
1323
  const p = platform();
@@ -782,7 +1327,302 @@ var DeviceTools = class {
782
1327
  linux: `ffplay -nodisp -autoexit "${file_path}"`
783
1328
  }[p];
784
1329
  await execAsync3(cmd);
785
- return { content: [{ type: "text", text: `\uC7AC\uC0DD \uC644\uB8CC: ${file_path}` }] };
1330
+ return { content: [{ type: "text", text: `Playback complete: ${file_path}` }] };
1331
+ }
1332
+ );
1333
+ }
1334
+ };
1335
+
1336
+ // src/setup/peekaboo-installer.ts
1337
+ var import_child_process4 = require("child_process");
1338
+ var import_util4 = require("util");
1339
+ var import_os = require("os");
1340
+ var execFileAsync2 = (0, import_util4.promisify)(import_child_process4.execFile);
1341
+ async function ensurePeekaboo() {
1342
+ if ((0, import_os.platform)() !== "darwin") return false;
1343
+ try {
1344
+ await execFileAsync2("which", ["peekaboo"]);
1345
+ return true;
1346
+ } catch {
1347
+ console.log("\u23F3 peekaboo not found, installing via brew...");
1348
+ try {
1349
+ await execFileAsync2("brew", ["tap", "steipete/tap"], { timeout: 3e4 });
1350
+ await execFileAsync2("brew", ["install", "peekaboo"], { timeout: 12e4 });
1351
+ console.log("\u2705 peekaboo installed");
1352
+ return true;
1353
+ } catch (brewErr) {
1354
+ console.warn("\u26A0\uFE0F peekaboo install failed:", brewErr.message);
1355
+ console.warn(" Desktop tools disabled. Install manually: brew tap steipete/tap && brew install peekaboo");
1356
+ return false;
1357
+ }
1358
+ }
1359
+ }
1360
+
1361
+ // src/tools/desktop.ts
1362
+ var import_execa = require("execa");
1363
+ var import_zod5 = require("zod");
1364
+ var import_fs = __toESM(require("fs"));
1365
+ var APP_BLACKLIST = /* @__PURE__ */ new Set([
1366
+ "Terminal",
1367
+ "iTerm2",
1368
+ "iTerm",
1369
+ "Finder"
1370
+ // 파일 삭제 위험
1371
+ ]);
1372
+ var consecutiveFailures = 0;
1373
+ var MAX_CONSECUTIVE_FAILURES = 2;
1374
+ async function peekaboo(args) {
1375
+ consecutiveFailures = 0;
1376
+ try {
1377
+ const { stdout } = await (0, import_execa.execa)("peekaboo", [...args, "--json-output"]);
1378
+ consecutiveFailures = 0;
1379
+ return JSON.parse(stdout);
1380
+ } catch (err) {
1381
+ consecutiveFailures++;
1382
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
1383
+ consecutiveFailures = 0;
1384
+ throw new Error(`peekaboo failed ${MAX_CONSECUTIVE_FAILURES} times in a row. Auto-stopped for safety. Last error: ${err.message}`);
1385
+ }
1386
+ throw err;
1387
+ }
1388
+ }
1389
+ function checkBlacklist(app) {
1390
+ if (app && APP_BLACKLIST.has(app)) {
1391
+ throw new Error(`App '${app}' is not allowed for automation (blacklisted for safety).`);
1392
+ }
1393
+ }
1394
+ var DesktopTools = class {
1395
+ register(server) {
1396
+ server.tool(
1397
+ "desktop_see",
1398
+ [
1399
+ "Capture the macOS Accessibility Tree snapshot for a running application. Returns structured element list with IDs, roles, labels, and positions.",
1400
+ "",
1401
+ "WORKFLOW: Call desktop_see \u2192 find target element \u2192 use its ID in desktop_click or desktop_type.",
1402
+ "Pass the returned snapshotId to subsequent calls for 240x speed improvement (cached lookup vs. full re-scan).",
1403
+ "",
1404
+ "SAFETY: Terminal, iTerm, and Finder are blocked. Two consecutive failures trigger an automatic safety stop."
1405
+ ].join("\n"),
1406
+ {
1407
+ app: import_zod5.z.string().optional().describe("App name to target (e.g. 'Safari', 'Notes', 'Google Chrome'). Omit for the frontmost app.")
1408
+ },
1409
+ async ({ app }) => {
1410
+ checkBlacklist(app);
1411
+ const args = ["see"];
1412
+ if (app) args.push("--app", app);
1413
+ const result = await peekaboo(args);
1414
+ const data = result.data;
1415
+ const snapshotId = data?.snapshot_id ?? result.snapshotId ?? result.snapshot_id;
1416
+ const elements = (data?.ui_elements ?? data?.elements ?? result.elements)?.map((e) => ({
1417
+ id: e.id,
1418
+ role: e.role,
1419
+ label: e.label,
1420
+ bounds: e.bounds
1421
+ })) ?? [];
1422
+ return {
1423
+ content: [{
1424
+ type: "text",
1425
+ text: JSON.stringify({ snapshotId, elements }, null, 2)
1426
+ }]
1427
+ };
1428
+ }
1429
+ );
1430
+ server.tool(
1431
+ "desktop_click",
1432
+ [
1433
+ "Click a macOS UI element by its accessibility label, ID, or x,y coordinates.",
1434
+ "",
1435
+ "The 'on' parameter accepts: element label text (e.g. 'Save'), accessibility ID from desktop_see, or coordinates as 'x,y' string.",
1436
+ "For faster interaction, pass the snapshotId from a recent desktop_see call.",
1437
+ "",
1438
+ "SAFETY: Terminal, iTerm, and Finder are blocked. Two consecutive failures trigger automatic safety stop."
1439
+ ].join("\n"),
1440
+ {
1441
+ on: import_zod5.z.string().describe("Element label, accessibility ID, or 'x,y' coordinates to click"),
1442
+ app: import_zod5.z.string().optional().describe("App name to target (e.g. 'Safari')"),
1443
+ snapshot: import_zod5.z.string().optional().describe("snapshotId from desktop_see for cached interaction (240x faster)"),
1444
+ doubleClick: import_zod5.z.boolean().optional().default(false).describe("Double-click instead of single click")
1445
+ },
1446
+ async ({ on, app, snapshot, doubleClick }) => {
1447
+ checkBlacklist(app);
1448
+ const args = ["click", "--on", on];
1449
+ if (app) args.push("--app", app);
1450
+ if (snapshot) args.push("--snapshot", snapshot);
1451
+ if (doubleClick) args.push("--double-click");
1452
+ const result = await peekaboo(args);
1453
+ return {
1454
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1455
+ };
1456
+ }
1457
+ );
1458
+ server.tool(
1459
+ "desktop_type",
1460
+ [
1461
+ "Type text into the currently focused UI element on macOS. The text is sent as keyboard input character-by-character.",
1462
+ "",
1463
+ "SAFETY: Terminal, iTerm, and Finder are blocked. Use desktop_see first to verify the correct element is focused."
1464
+ ].join("\n"),
1465
+ {
1466
+ text: import_zod5.z.string().describe("Text to type into the focused element"),
1467
+ app: import_zod5.z.string().optional().describe("App name to focus before typing")
1468
+ },
1469
+ async ({ text, app }) => {
1470
+ checkBlacklist(app);
1471
+ const args = ["type", text];
1472
+ if (app) args.push("--app", app);
1473
+ const result = await peekaboo(args);
1474
+ return {
1475
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1476
+ };
1477
+ }
1478
+ );
1479
+ server.tool(
1480
+ "desktop_hotkey",
1481
+ [
1482
+ "Press a keyboard shortcut on macOS. Keys are comma-separated.",
1483
+ "",
1484
+ "Common shortcuts: 'cmd,c' (copy), 'cmd,v' (paste), 'cmd,z' (undo), 'cmd,s' (save), 'cmd,w' (close tab), 'cmd,q' (quit), 'cmd,shift,t' (reopen tab), 'cmd,tab' (switch app).",
1485
+ "",
1486
+ "SAFETY: Terminal, iTerm, and Finder are blocked."
1487
+ ].join("\n"),
1488
+ {
1489
+ keys: import_zod5.z.string().describe("Comma-separated key combination (e.g. 'cmd,c', 'cmd,shift,t', 'escape', 'cmd,option,i')"),
1490
+ app: import_zod5.z.string().optional().describe("App name to target")
1491
+ },
1492
+ async ({ keys, app }) => {
1493
+ checkBlacklist(app);
1494
+ const args = ["hotkey", keys];
1495
+ if (app) args.push("--app", app);
1496
+ const result = await peekaboo(args);
1497
+ return {
1498
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1499
+ };
1500
+ }
1501
+ );
1502
+ server.tool(
1503
+ "desktop_scroll",
1504
+ "Scroll within a macOS application or specific UI element. Use 'ticks' to control scroll distance (default: 3). Can target a specific element by label or ID with the 'on' parameter.",
1505
+ {
1506
+ direction: import_zod5.z.enum(["up", "down", "left", "right"]).describe("Scroll direction"),
1507
+ ticks: import_zod5.z.number().optional().default(3).describe("Number of scroll ticks (default: 3). Higher = more scrolling."),
1508
+ on: import_zod5.z.string().optional().describe("Element label or ID to scroll within (from desktop_see). Omit to scroll the active area."),
1509
+ app: import_zod5.z.string().optional().describe("App name to target")
1510
+ },
1511
+ async ({ direction, ticks, on, app }) => {
1512
+ checkBlacklist(app);
1513
+ const args = ["scroll", "--direction", direction, "--amount", String(ticks)];
1514
+ if (on) args.push("--on", on);
1515
+ if (app) args.push("--app", app);
1516
+ const result = await peekaboo(args);
1517
+ return {
1518
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1519
+ };
1520
+ }
1521
+ );
1522
+ server.tool(
1523
+ "desktop_list_apps",
1524
+ "List all currently running applications on macOS. Returns app names that can be used as the 'app' parameter in other desktop tools (desktop_see, desktop_click, desktop_type, etc.).",
1525
+ {},
1526
+ async () => {
1527
+ try {
1528
+ const { stdout } = await (0, import_execa.execa)("peekaboo", ["list", "apps", "--json"]);
1529
+ return {
1530
+ content: [{ type: "text", text: stdout }]
1531
+ };
1532
+ } catch (err) {
1533
+ consecutiveFailures++;
1534
+ throw err;
1535
+ }
1536
+ }
1537
+ );
1538
+ server.tool(
1539
+ "desktop_list_windows",
1540
+ "List all open windows on macOS, optionally filtered by app name. If no app is specified, lists windows for the frontmost application. Useful for identifying which windows are available for automation.",
1541
+ {
1542
+ app: import_zod5.z.string().optional().describe("Filter by app name. Omit to query the frontmost app.")
1543
+ },
1544
+ async ({ app }) => {
1545
+ checkBlacklist(app);
1546
+ try {
1547
+ let targetApp = app;
1548
+ if (!targetApp) {
1549
+ const { stdout: stdout2 } = await (0, import_execa.execa)("osascript", [
1550
+ "-e",
1551
+ 'tell application "System Events" to get name of first application process whose frontmost is true'
1552
+ ]);
1553
+ targetApp = stdout2.trim();
1554
+ }
1555
+ const args = ["list", "windows", "--app", targetApp, "--json"];
1556
+ const { stdout } = await (0, import_execa.execa)("peekaboo", args);
1557
+ return {
1558
+ content: [{ type: "text", text: stdout }]
1559
+ };
1560
+ } catch (err) {
1561
+ consecutiveFailures++;
1562
+ throw err;
1563
+ }
1564
+ }
1565
+ );
1566
+ server.tool(
1567
+ "desktop_screenshot",
1568
+ [
1569
+ "Take a high-quality macOS screenshot using Peekaboo (Retina display support). Returns base64 image data.",
1570
+ "",
1571
+ "MODES: 'screen' captures the full display, 'window' captures a specific app window.",
1572
+ "Prefer desktop_see (Accessibility Tree) for understanding UI structure \u2014 use screenshot only when visual appearance matters (layouts, images, colors)."
1573
+ ].join("\n"),
1574
+ {
1575
+ app: import_zod5.z.string().optional().describe("Capture a specific app's window (by name)"),
1576
+ mode: import_zod5.z.enum(["screen", "window"]).optional().default("screen").describe("'screen': full display capture, 'window': specific app window only")
1577
+ },
1578
+ async ({ app, mode }) => {
1579
+ checkBlacklist(app);
1580
+ const args = ["image", "--mode", mode];
1581
+ if (app) args.push("--app", app);
1582
+ const result = await peekaboo(args);
1583
+ const data = result.data;
1584
+ const files = data?.files;
1585
+ const filePath = files?.[0]?.path;
1586
+ if (filePath) {
1587
+ const imageBuffer = import_fs.default.readFileSync(filePath);
1588
+ return {
1589
+ content: [{
1590
+ type: "image",
1591
+ data: imageBuffer.toString("base64"),
1592
+ mimeType: "image/png"
1593
+ }]
1594
+ };
1595
+ }
1596
+ return {
1597
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1598
+ };
1599
+ }
1600
+ );
1601
+ server.tool(
1602
+ "desktop_menu",
1603
+ [
1604
+ "Click a menu bar item in a macOS application. Navigate nested menus by adding path segments.",
1605
+ "",
1606
+ "Examples: ['File', 'New Tab'], ['Edit', 'Find', 'Find...'], ['View', 'Enter Full Screen'].",
1607
+ "The target app must be running and accessible."
1608
+ ].join("\n"),
1609
+ {
1610
+ path: import_zod5.z.array(import_zod5.z.string()).describe("Menu path as array (e.g. ['File', 'Save'], ['Edit', 'Find', 'Find...'])"),
1611
+ app: import_zod5.z.string().optional().describe("App name to target. Omit for the frontmost app.")
1612
+ },
1613
+ async ({ path: path2, app }) => {
1614
+ checkBlacklist(app);
1615
+ const args = ["menu", "click", "--path", path2.join(" > ")];
1616
+ if (app) args.push("--app", app);
1617
+ try {
1618
+ const { stdout } = await (0, import_execa.execa)("peekaboo", args);
1619
+ return {
1620
+ content: [{ type: "text", text: stdout || "Menu click executed" }]
1621
+ };
1622
+ } catch (err) {
1623
+ consecutiveFailures++;
1624
+ throw err;
1625
+ }
786
1626
  }
787
1627
  );
788
1628
  }
@@ -791,6 +1631,7 @@ var DeviceTools = class {
791
1631
  // src/server/mcp.ts
792
1632
  var mcpPort = 3e3;
793
1633
  var globalBrowserTools = null;
1634
+ var desktopToolsEnabled = false;
794
1635
  function createMcpServer() {
795
1636
  const server = new import_mcp.McpServer({
796
1637
  name: "junis",
@@ -805,6 +1646,10 @@ function createMcpServer() {
805
1646
  notebookTools.register(server);
806
1647
  const deviceTools = new DeviceTools();
807
1648
  deviceTools.register(server);
1649
+ if (desktopToolsEnabled) {
1650
+ const desktopTools = new DesktopTools();
1651
+ desktopTools.register(server);
1652
+ }
808
1653
  return server;
809
1654
  }
810
1655
  function readBody(req) {
@@ -913,6 +1758,10 @@ function handleOAuthDiscovery(req, res, port) {
913
1758
  async function startMCPServer(port) {
914
1759
  globalBrowserTools = new BrowserTools();
915
1760
  await globalBrowserTools.init();
1761
+ desktopToolsEnabled = await ensurePeekaboo();
1762
+ if (desktopToolsEnabled) {
1763
+ console.log("\u2705 Peekaboo available \u2014 desktop tools enabled");
1764
+ }
916
1765
  let resolvedPort = port;
917
1766
  const httpServer = (0, import_http.createServer)(
918
1767
  async (req, res) => {
@@ -1023,20 +1872,64 @@ async function handleMCPRequest(id, payload) {
1023
1872
  if (!res.ok) {
1024
1873
  throw new Error(`MCP request failed: ${res.status} ${res.statusText}`);
1025
1874
  }
1875
+ if (res.status === 202) {
1876
+ return null;
1877
+ }
1878
+ const contentType = res.headers.get("content-type") ?? "";
1879
+ if (contentType.includes("application/json")) {
1880
+ return res.json();
1881
+ }
1026
1882
  const text = await res.text();
1027
1883
  const lines = text.split("\n");
1884
+ let currentEventType = null;
1885
+ const collectedResults = [];
1886
+ let lastError = null;
1028
1887
  for (const line of lines) {
1029
- if (line.startsWith("data: ")) {
1888
+ if (line.startsWith("event: ")) {
1889
+ currentEventType = line.slice(7).trim();
1890
+ } else if (line.startsWith("data: ")) {
1891
+ const rawData = line.slice(6).trim();
1892
+ if (rawData === "") {
1893
+ currentEventType = null;
1894
+ continue;
1895
+ }
1030
1896
  try {
1031
- return JSON.parse(line.slice(6));
1897
+ const parsed = JSON.parse(rawData);
1898
+ if (currentEventType === "error") {
1899
+ lastError = parsed;
1900
+ } else if (currentEventType === "message" || currentEventType === null) {
1901
+ collectedResults.push(parsed);
1902
+ }
1032
1903
  } catch {
1033
1904
  }
1905
+ currentEventType = null;
1906
+ } else if (line === "") {
1907
+ currentEventType = null;
1908
+ }
1909
+ }
1910
+ if (collectedResults.length === 0 && lastError !== null) {
1911
+ throw new Error(
1912
+ `MCP error event: ${JSON.stringify(lastError)}`
1913
+ );
1914
+ }
1915
+ if (collectedResults.length > 1 && payload !== null && typeof payload === "object" && "id" in payload) {
1916
+ const requestId = payload.id;
1917
+ const matched = collectedResults.find(
1918
+ (r) => r !== null && typeof r === "object" && "id" in r && r.id === requestId
1919
+ );
1920
+ if (matched !== void 0) {
1921
+ return matched;
1034
1922
  }
1035
1923
  }
1924
+ if (collectedResults.length > 0) {
1925
+ return collectedResults[collectedResults.length - 1];
1926
+ }
1036
1927
  return null;
1037
1928
  }
1038
1929
  // Annotate the CommonJS export names for ESM import in node:
1039
1930
  0 && (module.exports = {
1931
+ checkPermission,
1040
1932
  handleMCPRequest,
1041
- startMCPServer
1933
+ startMCPServer,
1934
+ toolPermissions
1042
1935
  });