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.
@@ -6,6 +6,10 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __getProtoOf = Object.getPrototypeOf;
8
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
9
13
  var __copyProps = (to, from, except, desc) => {
10
14
  if (from && typeof from === "object" || typeof from === "function") {
11
15
  for (let key of __getOwnPropNames(from))
@@ -22,8 +26,14 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
26
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
27
  mod
24
28
  ));
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
25
30
 
26
31
  // src/server/stdio.ts
32
+ var stdio_exports = {};
33
+ __export(stdio_exports, {
34
+ startStdioServer: () => startStdioServer
35
+ });
36
+ module.exports = __toCommonJS(stdio_exports);
27
37
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
28
38
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
29
39
 
@@ -34,29 +44,96 @@ var import_promises = __toESM(require("fs/promises"));
34
44
  var import_path = __toESM(require("path"));
35
45
  var import_glob = require("glob");
36
46
  var import_zod = require("zod");
47
+
48
+ // src/server/permissions.ts
49
+ var toolPermissions = {
50
+ // 읽기 전용 — 자동 허용
51
+ browser_snapshot: "auto",
52
+ browser_screenshot: "auto",
53
+ desktop_see: "auto",
54
+ desktop_list_apps: "auto",
55
+ desktop_list_windows: "auto",
56
+ cron_list: "auto",
57
+ read_file: "auto",
58
+ list_directory: "auto",
59
+ list_processes: "auto",
60
+ search_code: "auto",
61
+ // 상호작용 — 확인 권장 (현재: auto와 동일하게 실행, 향후 UI 연동)
62
+ browser_click: "confirm",
63
+ browser_type: "confirm",
64
+ browser_navigate: "confirm",
65
+ browser_fill: "confirm",
66
+ browser_select: "confirm",
67
+ browser_press: "confirm",
68
+ browser_hover: "confirm",
69
+ browser_drag: "confirm",
70
+ browser_upload: "confirm",
71
+ browser_cookies: "confirm",
72
+ browser_storage: "confirm",
73
+ browser_dialog: "confirm",
74
+ desktop_click: "confirm",
75
+ desktop_type: "confirm",
76
+ desktop_hotkey: "confirm",
77
+ desktop_scroll: "confirm",
78
+ desktop_menu: "confirm",
79
+ desktop_screenshot: "confirm",
80
+ cron_create: "confirm",
81
+ cron_delete: "confirm",
82
+ edit_block: "confirm",
83
+ kill_process: "confirm",
84
+ // 시스템 변경 — 기본 차단 (PDF 7.3절)
85
+ execute_command: "deny",
86
+ write_file: "deny"
87
+ };
88
+ function checkPermission(toolName) {
89
+ const level = toolPermissions[toolName];
90
+ if (level === "deny") {
91
+ throw new Error(
92
+ `Tool '${toolName}' is blocked by permission policy (deny). To allow, update toolPermissions in src/server/permissions.ts.`
93
+ );
94
+ }
95
+ }
96
+
97
+ // src/tools/filesystem.ts
37
98
  var execAsync = (0, import_util.promisify)(import_child_process.exec);
38
99
  var execFileAsync = (0, import_util.promisify)(import_child_process.execFile);
39
100
  var FilesystemTools = class {
40
101
  register(server) {
41
102
  server.tool(
42
103
  "execute_command",
43
- "\uD130\uBBF8\uB110 \uBA85\uB839 \uC2E4\uD589",
104
+ [
105
+ "Execute a shell command on the user's local device.",
106
+ "",
107
+ "ROUTING:",
108
+ "- Use for system commands, package managers (npm, pip, brew), git, build tools, and scripting.",
109
+ "- For reading files prefer read_file, for editing prefer edit_block, for searching prefer search_code.",
110
+ "",
111
+ "BEHAVIOR:",
112
+ "- Safe, routine commands (ls, pwd, git status, echo): execute immediately without explanation.",
113
+ "- Destructive or irreversible commands (rm -rf, sudo, shutdown, mkfs): explain what will happen and get user confirmation first.",
114
+ "- If a command fails, analyze the error and suggest an alternative. Do not retry the identical command more than twice.",
115
+ "",
116
+ "SAFETY:",
117
+ "- 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.",
118
+ "- Avoid piping untrusted input into shells. Use absolute paths when possible. Quote paths containing spaces."
119
+ ].join("\n"),
44
120
  {
45
- command: import_zod.z.string().describe("\uC2E4\uD589\uD560 \uC258 \uBA85\uB839"),
46
- timeout_ms: import_zod.z.number().optional().default(3e4).describe("\uD0C0\uC784\uC544\uC6C3 (ms)"),
47
- background: import_zod.z.boolean().optional().default(false).describe("\uBC31\uADF8\uB77C\uC6B4\uB4DC \uC2E4\uD589")
121
+ command: import_zod.z.string().describe("The shell command to execute. Use absolute paths when possible. Quote paths containing spaces."),
122
+ timeout_ms: import_zod.z.number().optional().default(3e4).describe("Maximum execution time in milliseconds (default: 30000). Increase for long-running builds or downloads."),
123
+ background: import_zod.z.boolean().optional().default(false).describe("Run in background without waiting for completion. Use for servers or long-running processes.")
48
124
  },
49
125
  async ({ command, timeout_ms, background }) => {
126
+ checkPermission("execute_command");
50
127
  if (background) {
51
128
  (0, import_child_process.exec)(command);
52
- return { content: [{ type: "text", text: "\uBC31\uADF8\uB77C\uC6B4\uB4DC \uC2E4\uD589 \uC2DC\uC791\uB428" }] };
129
+ return { content: [{ type: "text", text: "Background execution started" }] };
53
130
  }
54
131
  try {
55
132
  const { stdout, stderr } = await execAsync(command, {
56
133
  timeout: timeout_ms
57
134
  });
58
135
  return {
59
- content: [{ type: "text", text: stdout || stderr || "(\uCD9C\uB825 \uC5C6\uC74C)" }]
136
+ content: [{ type: "text", text: stdout || stderr || "(no output)" }]
60
137
  };
61
138
  } catch (err) {
62
139
  const error = err;
@@ -64,7 +141,7 @@ var FilesystemTools = class {
64
141
  content: [
65
142
  {
66
143
  type: "text",
67
- text: `\uC624\uB958 (exit ${error.code ?? "?"}): ${error.message}
144
+ text: `Error (exit ${error.code ?? "?"}): ${error.message}
68
145
  ${error.stderr ?? ""}`
69
146
  }
70
147
  ],
@@ -75,10 +152,15 @@ ${error.stderr ?? ""}`
75
152
  );
76
153
  server.tool(
77
154
  "read_file",
78
- "\uD30C\uC77C \uC77D\uAE30",
155
+ [
156
+ "Read the contents of a file from the local filesystem.",
157
+ "",
158
+ "Returns file content as text (utf-8) or base64 for binary files. Supports any file type.",
159
+ "For searching within files, prefer search_code instead. For listing directory contents, use list_directory."
160
+ ].join("\n"),
79
161
  {
80
- path: import_zod.z.string().describe("\uD30C\uC77C \uACBD\uB85C"),
81
- encoding: import_zod.z.enum(["utf-8", "base64"]).optional().default("utf-8").describe("\uC778\uCF54\uB529")
162
+ path: import_zod.z.string().describe("Absolute or relative file path to read"),
163
+ 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)")
82
164
  },
83
165
  async ({ path: filePath, encoding }) => {
84
166
  try {
@@ -87,30 +169,39 @@ ${error.stderr ?? ""}`
87
169
  } catch (err) {
88
170
  const e = err;
89
171
  if (e.code === "ENOENT") {
90
- return { content: [{ type: "text", text: `\u274C \uD30C\uC77C\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${filePath}` }], isError: true };
172
+ return { content: [{ type: "text", text: `\u274C File not found: ${filePath}` }], isError: true };
91
173
  }
92
- return { content: [{ type: "text", text: `\u274C \uD30C\uC77C \uC77D\uAE30 \uC2E4\uD328: ${e.message}` }], isError: true };
174
+ return { content: [{ type: "text", text: `\u274C Failed to read file: ${e.message}` }], isError: true };
93
175
  }
94
176
  }
95
177
  );
96
178
  server.tool(
97
179
  "write_file",
98
- "\uD30C\uC77C \uC4F0\uAE30/\uC0DD\uC131",
180
+ [
181
+ "Create a new file or completely overwrite an existing file. Parent directories are created automatically.",
182
+ "",
183
+ "WARNING: This replaces the entire file content. For partial modifications, use edit_block instead.",
184
+ "Prefer edit_block over write_file for existing files \u2014 it's safer and preserves unmodified content."
185
+ ].join("\n"),
99
186
  {
100
- path: import_zod.z.string().describe("\uD30C\uC77C \uACBD\uB85C"),
101
- content: import_zod.z.string().describe("\uD30C\uC77C \uB0B4\uC6A9")
187
+ path: import_zod.z.string().describe("File path to create or overwrite. Parent directories are auto-created."),
188
+ content: import_zod.z.string().describe("Complete file content. This replaces the entire file.")
102
189
  },
103
190
  async ({ path: filePath, content }) => {
191
+ checkPermission("write_file");
104
192
  await import_promises.default.mkdir(import_path.default.dirname(filePath), { recursive: true });
105
193
  await import_promises.default.writeFile(filePath, content, "utf-8");
106
- return { content: [{ type: "text", text: "\uD30C\uC77C \uC800\uC7A5 \uC644\uB8CC" }] };
194
+ return { content: [{ type: "text", text: "File saved" }] };
107
195
  }
108
196
  );
109
197
  server.tool(
110
198
  "list_directory",
111
- "\uB514\uB809\uD1A0\uB9AC \uBAA9\uB85D \uC870\uD68C",
199
+ [
200
+ "List files and subdirectories in the specified path. Returns entries with type indicators (\u{1F4C1} directory, \u{1F4C4} file).",
201
+ "Use this to explore project structure before reading or modifying files."
202
+ ].join("\n"),
112
203
  {
113
- path: import_zod.z.string().describe("\uB514\uB809\uD1A0\uB9AC \uACBD\uB85C")
204
+ path: import_zod.z.string().describe("Directory path to list")
114
205
  },
115
206
  async ({ path: dirPath }) => {
116
207
  try {
@@ -120,19 +211,24 @@ ${error.stderr ?? ""}`
120
211
  } catch (err) {
121
212
  const e = err;
122
213
  if (e.code === "ENOENT") {
123
- return { content: [{ type: "text", text: `\u274C \uB514\uB809\uD1A0\uB9AC\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${dirPath}` }], isError: true };
214
+ return { content: [{ type: "text", text: `\u274C Directory not found: ${dirPath}` }], isError: true };
124
215
  }
125
- return { content: [{ type: "text", text: `\u274C \uB514\uB809\uD1A0\uB9AC \uC77D\uAE30 \uC2E4\uD328: ${e.message}` }], isError: true };
216
+ return { content: [{ type: "text", text: `\u274C Failed to read directory: ${e.message}` }], isError: true };
126
217
  }
127
218
  }
128
219
  );
129
220
  server.tool(
130
221
  "search_code",
131
- "\uCF54\uB4DC/\uD14D\uC2A4\uD2B8 \uAC80\uC0C9",
222
+ [
223
+ "Search for text patterns across files using regex. Uses ripgrep for speed with glob fallback.",
224
+ "",
225
+ "Use this to find code definitions, function references, configuration values, or any text pattern.",
226
+ "Returns matching lines with file paths and line numbers for precise navigation."
227
+ ].join("\n"),
132
228
  {
133
- pattern: import_zod.z.string().describe("\uAC80\uC0C9 \uD328\uD134 (\uC815\uADDC\uC2DD \uC9C0\uC6D0)"),
134
- directory: import_zod.z.string().optional().default(".").describe("\uAC80\uC0C9 \uB514\uB809\uD1A0\uB9AC"),
135
- file_pattern: import_zod.z.string().optional().default("**/*").describe("\uD30C\uC77C \uD328\uD134")
229
+ pattern: import_zod.z.string().describe("Search pattern with full regex support (e.g. 'function\\s+\\w+', 'import.*from', 'TODO')"),
230
+ directory: import_zod.z.string().optional().default(".").describe("Root directory to search from (default: current working directory)"),
231
+ file_pattern: import_zod.z.string().optional().default("**/*").describe("Glob pattern to filter files (e.g. '**/*.ts', '*.py', 'src/**/*.js')")
136
232
  },
137
233
  async ({ pattern, directory, file_pattern }) => {
138
234
  try {
@@ -141,7 +237,7 @@ ${error.stderr ?? ""}`
141
237
  ["--no-heading", "-n", pattern, directory],
142
238
  { timeout: 1e4 }
143
239
  );
144
- return { content: [{ type: "text", text: stdout || "\uACB0\uACFC \uC5C6\uC74C" }] };
240
+ return { content: [{ type: "text", text: stdout || "No results" }] };
145
241
  } catch {
146
242
  const safeDirectory = import_path.default.resolve(directory);
147
243
  const files = await (0, import_glob.glob)(file_pattern, { cwd: safeDirectory });
@@ -162,7 +258,7 @@ ${error.stderr ?? ""}`
162
258
  }
163
259
  return {
164
260
  content: [
165
- { type: "text", text: results.join("\n") || "\uACB0\uACFC \uC5C6\uC74C" }
261
+ { type: "text", text: results.join("\n") || "No results" }
166
262
  ]
167
263
  };
168
264
  }
@@ -170,7 +266,7 @@ ${error.stderr ?? ""}`
170
266
  );
171
267
  server.tool(
172
268
  "list_processes",
173
- "\uC2E4\uD589 \uC911\uC778 \uD504\uB85C\uC138\uC2A4 \uBAA9\uB85D",
269
+ "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.",
174
270
  {},
175
271
  async () => {
176
272
  const cmd = process.platform === "win32" ? "tasklist" : process.platform === "darwin" ? "ps aux | sort -rk 3 | head -30" : "ps aux --sort=-%cpu | head -30";
@@ -180,23 +276,27 @@ ${error.stderr ?? ""}`
180
276
  );
181
277
  server.tool(
182
278
  "kill_process",
183
- "\uD504\uB85C\uC138\uC2A4 \uC885\uB8CC (SIGTERM \uD6C4 3\uCD08 \uB300\uAE30, \uC0B4\uC544\uC788\uC73C\uBA74 SIGKILL \uC790\uB3D9 \uC801\uC6A9)",
279
+ [
280
+ "Terminate a process by PID. Default: sends SIGTERM (graceful shutdown), waits 3 seconds, then auto-applies SIGKILL if still alive.",
281
+ "",
282
+ "SAFETY: Only kill processes the user explicitly identifies. Never kill system-critical processes (init, systemd, loginwindow, WindowServer) without explicit instruction."
283
+ ].join("\n"),
184
284
  {
185
- pid: import_zod.z.number().describe("\uC885\uB8CC\uD560 \uD504\uB85C\uC138\uC2A4 PID"),
186
- 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)")
285
+ pid: import_zod.z.number().describe("PID of the process to terminate (use list_processes to find PIDs)"),
286
+ signal: import_zod.z.enum(["SIGTERM", "SIGKILL"]).optional().default("SIGTERM").describe("SIGTERM (default): graceful shutdown with 3s auto-SIGKILL fallback. SIGKILL: immediate force kill.")
187
287
  },
188
288
  async ({ pid, signal }) => {
189
289
  const isWindows = process.platform === "win32";
190
290
  if (isWindows) {
191
291
  await execAsync(`taskkill /PID ${pid} /F`);
192
292
  return {
193
- content: [{ type: "text", text: `PID ${pid} \uC885\uB8CC \uC644\uB8CC (taskkill /F)` }]
293
+ content: [{ type: "text", text: `PID ${pid} killed (taskkill /F)` }]
194
294
  };
195
295
  }
196
296
  if (signal === "SIGKILL") {
197
297
  await execAsync(`kill -9 ${pid}`);
198
298
  return {
199
- content: [{ type: "text", text: `PID ${pid} \uAC15\uC81C \uC885\uB8CC \uC644\uB8CC (SIGKILL)` }]
299
+ content: [{ type: "text", text: `PID ${pid} force killed (SIGKILL)` }]
200
300
  };
201
301
  }
202
302
  try {
@@ -204,7 +304,7 @@ ${error.stderr ?? ""}`
204
304
  } catch {
205
305
  return {
206
306
  content: [
207
- { 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.` }
307
+ { type: "text", text: `PID ${pid} kill failed: process does not exist or permission denied.` }
208
308
  ],
209
309
  isError: true
210
310
  };
@@ -213,7 +313,7 @@ ${error.stderr ?? ""}`
213
313
  const isAlive = await execAsync(`kill -0 ${pid}`).then(() => true).catch(() => false);
214
314
  if (!isAlive) {
215
315
  return {
216
- content: [{ type: "text", text: `PID ${pid} \uC885\uB8CC \uC644\uB8CC (SIGTERM)` }]
316
+ content: [{ type: "text", text: `PID ${pid} killed (SIGTERM)` }]
217
317
  };
218
318
  }
219
319
  await execAsync(`kill -9 ${pid}`);
@@ -221,7 +321,7 @@ ${error.stderr ?? ""}`
221
321
  content: [
222
322
  {
223
323
  type: "text",
224
- text: `PID ${pid} \uAC15\uC81C \uC885\uB8CC \uC644\uB8CC (SIGTERM \uBB34\uC751\uB2F5 \u2192 SIGKILL \uC790\uB3D9 \uC801\uC6A9)`
324
+ text: `PID ${pid} force killed (SIGTERM unresponsive, auto SIGKILL applied)`
225
325
  }
226
326
  ]
227
327
  };
@@ -229,17 +329,25 @@ ${error.stderr ?? ""}`
229
329
  );
230
330
  server.tool(
231
331
  "edit_block",
232
- "\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)",
332
+ [
333
+ "Replace a specific text block in a file with new text (diff-based partial edit).",
334
+ "",
335
+ "WORKFLOW: Always use read_file first to see current content, then use edit_block with the exact text to replace.",
336
+ "The old_string must match character-for-character including whitespace, indentation, and line breaks.",
337
+ "If multiple matches exist, include more surrounding context to make it unique, or set replace_all=true.",
338
+ "",
339
+ "Prefer this over write_file for modifying existing files \u2014 it only changes what you specify and preserves the rest."
340
+ ].join("\n"),
233
341
  {
234
- path: import_zod.z.string().describe("\uD30C\uC77C \uACBD\uB85C"),
235
- old_string: import_zod.z.string().describe("\uAD50\uCCB4\uD560 \uAE30\uC874 \uD14D\uC2A4\uD2B8 (\uC815\uD655\uD788 \uC77C\uCE58\uD574\uC57C \uD568)"),
236
- new_string: import_zod.z.string().describe("\uC0C8 \uD14D\uC2A4\uD2B8"),
237
- replace_all: import_zod.z.boolean().optional().default(false).describe("true\uBA74 \uBAA8\uB4E0 \uB9E4\uCE6D \uAD50\uCCB4, false\uBA74 \uCCAB \uBC88\uC9F8\uB9CC")
342
+ path: import_zod.z.string().describe("Path to the file to edit. The file must already exist."),
343
+ 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."),
344
+ new_string: import_zod.z.string().describe("The replacement text. Use empty string to delete the matched text."),
345
+ 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).")
238
346
  },
239
347
  async ({ path: filePath, old_string, new_string, replace_all }) => {
240
348
  const content = await import_promises.default.readFile(filePath, "utf-8");
241
349
  if (!content.includes(old_string)) {
242
- throw new Error(`old_string\uC744 \uD30C\uC77C\uC5D0\uC11C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${filePath}`);
350
+ throw new Error(`old_string not found in file: ${filePath}`);
243
351
  }
244
352
  let count = 0;
245
353
  let pos = 0;
@@ -249,7 +357,7 @@ ${error.stderr ?? ""}`
249
357
  }
250
358
  if (!replace_all && count > 1) {
251
359
  throw new Error(
252
- `\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.`
360
+ `Found ${count} matches. Set replace_all to true or include more context to narrow it down.`
253
361
  );
254
362
  }
255
363
  let result;
@@ -263,21 +371,202 @@ ${error.stderr ?? ""}`
263
371
  }
264
372
  await import_promises.default.writeFile(filePath, result, "utf-8");
265
373
  return {
266
- content: [{ type: "text", text: `\uAD50\uCCB4 \uC644\uB8CC (${replaced}\uAC1C \uBCC0\uACBD\uB428)` }]
374
+ content: [{ type: "text", text: `Replaced (${replaced} occurrence(s) changed)` }]
267
375
  };
268
376
  }
269
377
  );
378
+ server.tool(
379
+ "cron_create",
380
+ [
381
+ "Create a recurring scheduled task (cron job) using standard cron syntax.",
382
+ "",
383
+ "Common schedules: '*/5 * * * *' (every 5 min), '0 9 * * 1-5' (weekdays 9am), '0 0 * * *' (daily midnight), '0 */2 * * *' (every 2 hours).",
384
+ "Duplicate commands are automatically detected and rejected. Use cron_list to see existing jobs."
385
+ ].join("\n"),
386
+ {
387
+ 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)"),
388
+ command: import_zod.z.string().describe("Shell command to execute on schedule"),
389
+ label: import_zod.z.string().optional().describe("Human-readable label for identification (e.g. 'daily-backup', 'log-cleanup')")
390
+ },
391
+ async ({ schedule, command, label }) => {
392
+ try {
393
+ let existing = "";
394
+ try {
395
+ const { stdout } = await execAsync("crontab -l");
396
+ existing = stdout;
397
+ } catch {
398
+ }
399
+ if (existing.includes(command)) {
400
+ return {
401
+ content: [{ type: "text", text: `\u26A0\uFE0F A cron job with this command already exists.` }],
402
+ isError: true
403
+ };
404
+ }
405
+ const comment = label ? `# junis:${label}
406
+ ` : "# junis-cron\n";
407
+ const newEntry = `${comment}${schedule} ${command}
408
+ `;
409
+ const updated = existing.trimEnd() + "\n" + newEntry;
410
+ const tmpFile = `/tmp/junis_crontab_${Date.now()}.txt`;
411
+ await import_promises.default.writeFile(tmpFile, updated, "utf-8");
412
+ await execAsync(`crontab ${tmpFile}`);
413
+ await import_promises.default.unlink(tmpFile).catch(() => {
414
+ });
415
+ return {
416
+ content: [{ type: "text", text: `\u2705 Cron job created:
417
+ schedule: ${schedule}
418
+ command: ${command}${label ? `
419
+ label: ${label}` : ""}` }]
420
+ };
421
+ } catch (err) {
422
+ return {
423
+ content: [{ type: "text", text: `\u274C Failed to create cron job: ${err.message}` }],
424
+ isError: true
425
+ };
426
+ }
427
+ }
428
+ );
429
+ server.tool(
430
+ "cron_list",
431
+ "List all scheduled cron jobs with their IDs, labels, schedules, and commands. Use the returned ID numbers with cron_delete to remove specific jobs.",
432
+ {},
433
+ async () => {
434
+ try {
435
+ const { stdout } = await execAsync("crontab -l");
436
+ const lines = stdout.trim().split("\n").filter((l) => l.trim());
437
+ if (lines.length === 0) {
438
+ return { content: [{ type: "text", text: "No cron jobs found." }] };
439
+ }
440
+ const entries = [];
441
+ let pendingLabel;
442
+ let id = 1;
443
+ for (const line of lines) {
444
+ if (line.startsWith("#")) {
445
+ const match = line.match(/^# junis:(.+)$/);
446
+ pendingLabel = match ? match[1].trim() : void 0;
447
+ continue;
448
+ }
449
+ const parts = line.split(/\s+/);
450
+ if (parts.length >= 6) {
451
+ const schedule = parts.slice(0, 5).join(" ");
452
+ const command = parts.slice(5).join(" ");
453
+ entries.push({ id: id++, label: pendingLabel, schedule, command });
454
+ }
455
+ pendingLabel = void 0;
456
+ }
457
+ if (entries.length === 0) {
458
+ return { content: [{ type: "text", text: stdout }] };
459
+ }
460
+ const output = entries.map(
461
+ (e) => `[${e.id}] ${e.label ? `(${e.label}) ` : ""}${e.schedule} \u2192 ${e.command}`
462
+ ).join("\n");
463
+ return { content: [{ type: "text", text: output }] };
464
+ } catch (err) {
465
+ const e = err;
466
+ if (e.code === 1) {
467
+ return { content: [{ type: "text", text: "No cron jobs found (crontab is empty)." }] };
468
+ }
469
+ return {
470
+ content: [{ type: "text", text: `\u274C Failed to list cron jobs: ${e.message}` }],
471
+ isError: true
472
+ };
473
+ }
474
+ }
475
+ );
476
+ server.tool(
477
+ "cron_delete",
478
+ "Delete a scheduled cron job by its ID (from cron_list output) or by matching command string. Associated comment labels are automatically cleaned up.",
479
+ {
480
+ id: import_zod.z.number().optional().describe("Cron job ID from cron_list output (e.g. 1, 2, 3)"),
481
+ command: import_zod.z.string().optional().describe("Delete all jobs matching this command string")
482
+ },
483
+ async ({ id, command }) => {
484
+ if (!id && !command) {
485
+ return {
486
+ content: [{ type: "text", text: "\u274C Provide either id or command to identify the cron job." }],
487
+ isError: true
488
+ };
489
+ }
490
+ try {
491
+ let existing = "";
492
+ try {
493
+ const { stdout } = await execAsync("crontab -l");
494
+ existing = stdout;
495
+ } catch {
496
+ return { content: [{ type: "text", text: "No cron jobs to delete." }] };
497
+ }
498
+ const lines = existing.split("\n");
499
+ if (command) {
500
+ const filtered2 = [];
501
+ for (let i = 0; i < lines.length; i++) {
502
+ if (lines[i].includes(command)) {
503
+ if (filtered2.length > 0 && filtered2[filtered2.length - 1].trim().startsWith("#")) {
504
+ filtered2.pop();
505
+ }
506
+ continue;
507
+ }
508
+ filtered2.push(lines[i]);
509
+ }
510
+ if (filtered2.length === lines.length) {
511
+ return {
512
+ content: [{ type: "text", text: `\u274C No cron job found matching: ${command}` }],
513
+ isError: true
514
+ };
515
+ }
516
+ const updated2 = filtered2.join("\n");
517
+ const tmpFile2 = `/tmp/junis_crontab_${Date.now()}.txt`;
518
+ await import_promises.default.writeFile(tmpFile2, updated2, "utf-8");
519
+ await execAsync(`crontab ${tmpFile2}`);
520
+ await import_promises.default.unlink(tmpFile2).catch(() => {
521
+ });
522
+ return { content: [{ type: "text", text: `\u2705 Deleted cron job matching: ${command}` }] };
523
+ }
524
+ const entries = [];
525
+ let idx = 1;
526
+ for (let i = 0; i < lines.length; i++) {
527
+ const line = lines[i].trim();
528
+ if (line.startsWith("#")) continue;
529
+ const parts = line.split(/\s+/);
530
+ if (parts.length >= 6) {
531
+ const prevIsComment = i > 0 && lines[i - 1].trim().startsWith("#");
532
+ entries.push({ lineStart: prevIsComment ? i - 1 : i, lineEnd: i, idx: idx++ });
533
+ }
534
+ }
535
+ const target = entries.find((e) => e.idx === id);
536
+ if (!target) {
537
+ return {
538
+ content: [{ type: "text", text: `\u274C No cron job found with id=${id}. Use cron_list to see current IDs.` }],
539
+ isError: true
540
+ };
541
+ }
542
+ const filtered = lines.filter((_, i) => i < target.lineStart || i > target.lineEnd);
543
+ const updated = filtered.join("\n");
544
+ const tmpFile = `/tmp/junis_crontab_${Date.now()}.txt`;
545
+ await import_promises.default.writeFile(tmpFile, updated, "utf-8");
546
+ await execAsync(`crontab ${tmpFile}`);
547
+ await import_promises.default.unlink(tmpFile).catch(() => {
548
+ });
549
+ return { content: [{ type: "text", text: `\u2705 Deleted cron job #${id}` }] };
550
+ } catch (err) {
551
+ return {
552
+ content: [{ type: "text", text: `\u274C Failed to delete cron job: ${err.message}` }],
553
+ isError: true
554
+ };
555
+ }
556
+ }
557
+ );
270
558
  }
271
559
  };
272
560
 
273
561
  // src/tools/browser.ts
274
- var import_playwright = require("playwright");
562
+ var import_browserclaw = require("browserclaw");
563
+ var import_promises2 = __toESM(require("fs/promises"));
275
564
  var import_zod2 = require("zod");
276
565
  var BrowserTools = class {
277
566
  browser = null;
278
567
  page = null;
279
- // 동시 요청 시 race condition 방지용 직렬화 락
280
568
  lock = Promise.resolve();
569
+ armedDialog = null;
281
570
  withLock(fn) {
282
571
  let release;
283
572
  const next = new Promise((r) => {
@@ -287,128 +576,403 @@ var BrowserTools = class {
287
576
  this.lock = this.lock.then(() => next);
288
577
  return current.then(() => fn()).finally(() => release());
289
578
  }
579
+ /** mcp.ts에서 호출하는 init — BrowserClaw는 browser_start 도구로 명시적 시작하므로 noop */
290
580
  async init() {
291
- try {
292
- this.browser = await import_playwright.chromium.launch({ headless: true });
293
- this.page = await this.browser.newPage();
294
- } catch {
295
- console.warn(
296
- "\u26A0\uFE0F Playwright \uBBF8\uC124\uCE58. \uBE0C\uB77C\uC6B0\uC800 \uB3C4\uAD6C \uBE44\uD65C\uC131\uD654.\n \uD65C\uC131\uD654: npx playwright install chromium"
297
- );
298
- }
299
581
  }
300
582
  async cleanup() {
301
- await this.browser?.close();
583
+ await this.browser?.stop();
584
+ this.browser = null;
585
+ this.page = null;
302
586
  }
303
587
  register(server) {
304
588
  const requirePage = () => {
305
- if (!this.page) throw new Error("\uBE0C\uB77C\uC6B0\uC800 \uBBF8\uCD08\uAE30\uD654. playwright \uC124\uCE58 \uD655\uC778.");
589
+ if (!this.page) throw new Error("Browser not started. Call browser_start first.");
306
590
  return this.page;
307
591
  };
592
+ server.tool(
593
+ "browser_start",
594
+ [
595
+ "Launch or connect to a web browser for automation.",
596
+ "",
597
+ "MODES:",
598
+ "- 'managed' (default): Launches a new Chromium instance. Use 'headless' for background operation, 'profile' for persistent sessions (cookies, logins preserved).",
599
+ "- 'remote-cdp': Connects to an already-running Chrome via CDP URL (e.g. from chrome://inspect). Use this to automate an existing browser session.",
600
+ "",
601
+ "WORKFLOW: browser_start \u2192 browser_navigate \u2192 browser_snapshot \u2192 interact (click/type/fill) \u2192 browser_stop.",
602
+ "Always call browser_stop when done to release system resources."
603
+ ].join("\n"),
604
+ {
605
+ mode: import_zod2.z.enum(["managed", "remote-cdp"]).optional().default("managed").describe("'managed' = launch new browser, 'remote-cdp' = connect to existing Chrome via CDP"),
606
+ headless: import_zod2.z.boolean().optional().default(false).describe("Run without visible window (managed mode only). Use for background tasks."),
607
+ cdpUrl: import_zod2.z.string().optional().describe("Chrome DevTools Protocol URL for remote-cdp mode (e.g. http://localhost:9222)"),
608
+ profile: import_zod2.z.string().optional().describe("Browser profile name for persistent sessions \u2014 preserves cookies, logins, and history across restarts (managed mode only)"),
609
+ allowInternal: import_zod2.z.boolean().optional().default(false).describe("Allow navigation to localhost and internal network URLs")
610
+ },
611
+ ({ mode, headless, cdpUrl, profile, allowInternal }) => this.withLock(async () => {
612
+ if (this.browser) {
613
+ return { content: [{ type: "text", text: "Browser is already running. Call browser_stop first." }] };
614
+ }
615
+ if (mode === "remote-cdp") {
616
+ if (!cdpUrl) throw new Error("cdpUrl is required for remote-cdp mode");
617
+ this.browser = await import_browserclaw.BrowserClaw.connect(cdpUrl, { allowInternal });
618
+ } else {
619
+ this.browser = await import_browserclaw.BrowserClaw.launch({
620
+ headless,
621
+ profileName: profile,
622
+ allowInternal
623
+ });
624
+ }
625
+ return { content: [{ type: "text", text: `Browser started (mode: ${mode})` }] };
626
+ })
627
+ );
628
+ server.tool(
629
+ "browser_stop",
630
+ "Stop the browser and release all associated resources (memory, connections, processes). Always call this when browser automation is complete.",
631
+ {},
632
+ () => this.withLock(async () => {
633
+ await this.cleanup();
634
+ return { content: [{ type: "text", text: "Browser stopped" }] };
635
+ })
636
+ );
308
637
  server.tool(
309
638
  "browser_navigate",
310
- "URL\uB85C \uC774\uB3D9",
311
- { url: import_zod2.z.string().describe("\uC774\uB3D9\uD560 URL") },
639
+ "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.",
640
+ {
641
+ url: import_zod2.z.string().describe("Full URL to navigate to (include https://)")
642
+ },
312
643
  ({ url }) => this.withLock(async () => {
313
- const page = requirePage();
314
- await page.goto(url, { waitUntil: "domcontentloaded" });
644
+ if (!this.browser) throw new Error("Browser not started. Call browser_start first.");
645
+ if (!this.page) {
646
+ this.page = await this.browser.open(url);
647
+ } else {
648
+ await this.page.goto(url);
649
+ }
650
+ const currentUrl = await this.page.url();
651
+ return { content: [{ type: "text", text: `Navigated to: ${currentUrl}` }] };
652
+ })
653
+ );
654
+ server.tool(
655
+ "browser_snapshot",
656
+ [
657
+ "Capture the page's Accessibility Tree with numbered ref IDs for each element. This is the primary way to 'see' and understand page content.",
658
+ "",
659
+ "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.",
660
+ "Refs change after page updates \u2014 always call browser_snapshot again after navigation or clicks that modify the page.",
661
+ "",
662
+ "Prefer this over browser_screenshot for understanding page structure \u2014 it's faster, structured, and machine-readable."
663
+ ].join("\n"),
664
+ {
665
+ interactive: import_zod2.z.boolean().optional().default(true).describe("true (default): only show clickable/typeable elements. false: show all elements including static text."),
666
+ compact: import_zod2.z.boolean().optional().default(true).describe("true (default): hide empty containers for cleaner output")
667
+ },
668
+ ({ interactive, compact }) => this.withLock(async () => {
669
+ const result = await requirePage().snapshot({ interactive, compact });
670
+ const { snapshot, refs, stats } = result;
671
+ const refList = Object.entries(refs).map(([r, info]) => ` ${r}: ${info.role} "${info.name ?? ""}"`).join("\n");
672
+ const total = stats?.refs ?? Object.keys(refs).length;
315
673
  return {
316
- content: [{ type: "text", text: `\uC774\uB3D9 \uC644\uB8CC: ${page.url()}` }]
674
+ content: [{
675
+ type: "text",
676
+ text: `${snapshot}
677
+
678
+ --- refs (${total} total) ---
679
+ ${refList}`
680
+ }]
317
681
  };
318
682
  })
319
683
  );
320
684
  server.tool(
321
685
  "browser_click",
322
- "\uC694\uC18C \uD074\uB9AD",
323
- { selector: import_zod2.z.string().describe("CSS \uC120\uD0DD\uC790") },
324
- ({ selector }) => this.withLock(async () => {
325
- await requirePage().click(selector);
326
- return { content: [{ type: "text", text: "\uD074\uB9AD \uC644\uB8CC" }] };
686
+ "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.",
687
+ {
688
+ ref: import_zod2.z.string().describe("Element ref from browser_snapshot (e.g. 'e1', 'e15'). Call browser_snapshot first to get current refs."),
689
+ doubleClick: import_zod2.z.boolean().optional().default(false).describe("Double-click instead of single click"),
690
+ button: import_zod2.z.enum(["left", "right", "middle"]).optional().default("left").describe("Mouse button to use")
691
+ },
692
+ ({ ref, doubleClick, button }) => this.withLock(async () => {
693
+ await requirePage().click(ref, { doubleClick, button });
694
+ return { content: [{ type: "text", text: `Clicked ref=${ref}` }] };
327
695
  })
328
696
  );
329
697
  server.tool(
330
698
  "browser_type",
331
- "\uD14D\uC2A4\uD2B8 \uC785\uB825",
699
+ "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.",
332
700
  {
333
- selector: import_zod2.z.string().describe("CSS \uC120\uD0DD\uC790"),
334
- text: import_zod2.z.string().describe("\uC785\uB825\uD560 \uD14D\uC2A4\uD2B8"),
335
- clear: import_zod2.z.boolean().optional().default(false).describe("\uAE30\uC874 \uB0B4\uC6A9 \uC0AD\uC81C \uD6C4 \uC785\uB825")
701
+ ref: import_zod2.z.string().describe("Element ref from browser_snapshot (e.g. 'e3')"),
702
+ text: import_zod2.z.string().describe("Text to type into the element"),
703
+ submit: import_zod2.z.boolean().optional().default(false).describe("Press Enter after typing (useful for search boxes and forms)"),
704
+ slowly: import_zod2.z.boolean().optional().default(false).describe("Type slowly (75ms per char) for sites that process each keystroke")
336
705
  },
337
- ({ selector, text, clear }) => this.withLock(async () => {
338
- const page = requirePage();
339
- if (clear) await page.fill(selector, text);
340
- else await page.type(selector, text);
341
- return { content: [{ type: "text", text: "\uC785\uB825 \uC644\uB8CC" }] };
706
+ ({ ref, text, submit, slowly }) => this.withLock(async () => {
707
+ await requirePage().type(ref, text, { submit, slowly });
708
+ return { content: [{ type: "text", text: `Typed into ref=${ref}` }] };
709
+ })
710
+ );
711
+ server.tool(
712
+ "browser_fill",
713
+ "Fill multiple form fields at once \u2014 more efficient than calling browser_type repeatedly. Each field needs a ref from browser_snapshot.",
714
+ {
715
+ fields: import_zod2.z.array(import_zod2.z.object({
716
+ ref: import_zod2.z.string(),
717
+ type: import_zod2.z.enum(["text", "checkbox", "radio"]),
718
+ value: import_zod2.z.union([import_zod2.z.string(), import_zod2.z.boolean()])
719
+ })).describe("Array of {ref, type, value}. type='text': value is string. type='checkbox'/'radio': value is boolean.")
720
+ },
721
+ ({ fields }) => this.withLock(async () => {
722
+ await requirePage().fill(fields);
723
+ return { content: [{ type: "text", text: `Filled ${fields.length} field(s)` }] };
724
+ })
725
+ );
726
+ server.tool(
727
+ "browser_select",
728
+ "Select one or more options from a dropdown/select element. Values should match the option value attributes, not display text.",
729
+ {
730
+ ref: import_zod2.z.string().describe("Ref of the <select> element from browser_snapshot"),
731
+ values: import_zod2.z.array(import_zod2.z.string()).describe("Option value(s) to select")
732
+ },
733
+ ({ ref, values }) => this.withLock(async () => {
734
+ await requirePage().select(ref, ...values);
735
+ return { content: [{ type: "text", text: `Selected option(s) in ref=${ref}` }] };
736
+ })
737
+ );
738
+ server.tool(
739
+ "browser_press",
740
+ "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.",
741
+ {
742
+ key: import_zod2.z.string().describe("Key or combination: 'Enter', 'Escape', 'Tab', 'Control+a', 'Meta+c', 'ArrowDown', 'Backspace'")
743
+ },
744
+ ({ key }) => this.withLock(async () => {
745
+ await requirePage().press(key);
746
+ return { content: [{ type: "text", text: `Pressed: ${key}` }] };
747
+ })
748
+ );
749
+ server.tool(
750
+ "browser_hover",
751
+ "Move the mouse cursor over an element by ref. Use to trigger hover menus, tooltips, or dropdown previews before clicking.",
752
+ {
753
+ ref: import_zod2.z.string().describe("Element ref from browser_snapshot")
754
+ },
755
+ ({ ref }) => this.withLock(async () => {
756
+ await requirePage().hover(ref);
757
+ return { content: [{ type: "text", text: `Hovered over ref=${ref}` }] };
758
+ })
759
+ );
760
+ server.tool(
761
+ "browser_drag",
762
+ "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.",
763
+ {
764
+ startRef: import_zod2.z.string().describe("Source element ref to drag from"),
765
+ endRef: import_zod2.z.string().describe("Target element ref to drag to")
766
+ },
767
+ ({ startRef, endRef }) => this.withLock(async () => {
768
+ await requirePage().drag(startRef, endRef);
769
+ return { content: [{ type: "text", text: `Dragged ref=${startRef} \u2192 ref=${endRef}` }] };
770
+ })
771
+ );
772
+ server.tool(
773
+ "browser_upload",
774
+ "Upload local files to a file input element (<input type='file'>). The ref must point to a file input from browser_snapshot.",
775
+ {
776
+ ref: import_zod2.z.string().describe("Ref of the file input element from browser_snapshot"),
777
+ paths: import_zod2.z.array(import_zod2.z.string()).describe("Absolute file path(s) on the local device to upload")
778
+ },
779
+ ({ ref, paths }) => this.withLock(async () => {
780
+ await requirePage().uploadFile(ref, paths);
781
+ return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ref=${ref}` }] };
342
782
  })
343
783
  );
344
784
  server.tool(
345
785
  "browser_screenshot",
346
- "\uD604\uC7AC \uD398\uC774\uC9C0 \uC2A4\uD06C\uB9B0\uC0F7",
786
+ [
787
+ "Capture a screenshot of the current page. Returns base64 image data (viewable by AI) or saves to a file.",
788
+ "",
789
+ "Prefer browser_snapshot (Accessibility Tree) for understanding page structure \u2014 it's faster and machine-readable.",
790
+ "Use browser_screenshot only when visual layout matters (charts, images, styling, visual verification)."
791
+ ].join("\n"),
347
792
  {
348
- path: import_zod2.z.string().optional().describe("\uC800\uC7A5 \uACBD\uB85C (\uC5C6\uC73C\uBA74 base64 \uBC18\uD658)"),
349
- full_page: import_zod2.z.boolean().optional().default(false)
793
+ path: import_zod2.z.string().optional().describe("Save path for the screenshot. If omitted, returns base64 image data directly."),
794
+ fullPage: import_zod2.z.boolean().optional().default(false).describe("Capture the full scrollable page, not just the visible viewport"),
795
+ ref: import_zod2.z.string().optional().describe("Capture only a specific element by its ref from browser_snapshot")
350
796
  },
351
- ({ path: path2, full_page }) => this.withLock(async () => {
352
- const page = requirePage();
353
- const screenshot = await page.screenshot({
354
- path: path2 ?? void 0,
355
- fullPage: full_page
356
- });
797
+ ({ path: path2, fullPage, ref }) => this.withLock(async () => {
798
+ const buffer = await requirePage().screenshot({ fullPage, ref });
357
799
  if (path2) {
358
- return { content: [{ type: "text", text: `\uC800\uC7A5 \uC644\uB8CC: ${path2}` }] };
800
+ await import_promises2.default.writeFile(path2, buffer);
801
+ return { content: [{ type: "text", text: `Screenshot saved: ${path2}` }] };
359
802
  }
360
803
  return {
361
- content: [
362
- {
363
- type: "image",
364
- data: screenshot.toString("base64"),
365
- mimeType: "image/png"
366
- }
367
- ]
804
+ content: [{
805
+ type: "image",
806
+ data: buffer.toString("base64"),
807
+ mimeType: "image/png"
808
+ }]
368
809
  };
369
810
  })
370
811
  );
371
812
  server.tool(
372
- "browser_snapshot",
373
- "\uD398\uC774\uC9C0 \uC811\uADFC\uC131 \uD2B8\uB9AC \uC870\uD68C (\uAD6C\uC870 \uD30C\uC545\uC6A9)",
374
- {},
375
- () => this.withLock(async () => {
376
- const page = requirePage();
377
- const snapshot = await page.locator("body").ariaSnapshot();
378
- return {
379
- content: [
380
- { type: "text", text: snapshot }
381
- ]
382
- };
813
+ "browser_pdf",
814
+ "Save the current page as a PDF file. Renders the full page including below-the-fold content. Useful for archiving, sharing, or offline reading.",
815
+ {
816
+ path: import_zod2.z.string().describe("Output file path (.pdf)")
817
+ },
818
+ ({ path: path2 }) => this.withLock(async () => {
819
+ const buffer = await requirePage().pdf();
820
+ await import_promises2.default.writeFile(path2, buffer);
821
+ return { content: [{ type: "text", text: `PDF saved: ${path2}` }] };
383
822
  })
384
823
  );
385
824
  server.tool(
386
825
  "browser_evaluate",
387
- "JavaScript \uC2E4\uD589",
388
- { code: import_zod2.z.string().describe("\uC2E4\uD589\uD560 JavaScript \uCF54\uB4DC") },
826
+ [
827
+ "Execute JavaScript code directly in the browser page context and return the result.",
828
+ "",
829
+ "Use for: extracting data not available in the Accessibility Tree, DOM manipulation, interacting with page APIs, or debugging.",
830
+ "Wrap complex logic in an IIFE: (function(){ ... })()"
831
+ ].join("\n"),
832
+ {
833
+ code: import_zod2.z.string().describe("JavaScript code to execute in the page context. Return values are automatically serialized.")
834
+ },
389
835
  ({ code }) => this.withLock(async () => {
390
836
  try {
391
837
  const result = await requirePage().evaluate(code);
392
838
  return {
393
- content: [
394
- { type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }
395
- ]
839
+ content: [{
840
+ type: "text",
841
+ text: typeof result === "string" ? result : JSON.stringify(result, null, 2)
842
+ }]
396
843
  };
397
844
  } catch (err) {
398
845
  return {
399
- content: [{ type: "text", text: `\u274C JavaScript \uC2E4\uD589 \uC624\uB958: ${err.message}` }],
846
+ content: [{ type: "text", text: `\u274C JS error: ${err.message}` }],
400
847
  isError: true
401
848
  };
402
849
  }
403
850
  })
404
851
  );
405
852
  server.tool(
406
- "browser_pdf",
407
- "\uD604\uC7AC \uD398\uC774\uC9C0 PDF \uC800\uC7A5",
408
- { path: import_zod2.z.string().describe("\uC800\uC7A5 \uACBD\uB85C (.pdf)") },
409
- ({ path: path2 }) => this.withLock(async () => {
410
- await requirePage().pdf({ path: path2 });
411
- return { content: [{ type: "text", text: `PDF \uC800\uC7A5 \uC644\uB8CC: ${path2}` }] };
853
+ "browser_wait",
854
+ [
855
+ "Wait for a specific condition before proceeding. Use between actions when the page needs time to update.",
856
+ "",
857
+ "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)."
858
+ ].join("\n"),
859
+ {
860
+ text: import_zod2.z.string().optional().describe("Wait until this text appears on the page"),
861
+ textGone: import_zod2.z.string().optional().describe("Wait until this text disappears from the page"),
862
+ url: import_zod2.z.string().optional().describe("Wait until URL matches this glob pattern (e.g. '**/dashboard', '**/success')"),
863
+ loadState: import_zod2.z.enum(["load", "domcontentloaded", "networkidle"]).optional().describe("Wait for page load state: 'load' (full), 'domcontentloaded' (DOM ready), 'networkidle' (no pending requests)"),
864
+ timeMs: import_zod2.z.number().optional().describe("Fixed wait in milliseconds \u2014 use as last resort when other conditions don't apply")
865
+ },
866
+ ({ text, textGone, url, loadState, timeMs }) => this.withLock(async () => {
867
+ const condition = {};
868
+ if (text) condition.text = text;
869
+ if (textGone) condition.textGone = textGone;
870
+ if (url) condition.url = url;
871
+ if (loadState) condition.loadState = loadState;
872
+ if (timeMs) condition.timeMs = timeMs;
873
+ await requirePage().waitFor(condition);
874
+ return { content: [{ type: "text", text: "Wait condition met" }] };
875
+ })
876
+ );
877
+ server.tool(
878
+ "browser_cookies",
879
+ "Manage browser cookies: get all cookies, set a specific cookie, or clear all cookies. Useful for authentication state, session management, or testing.",
880
+ {
881
+ action: import_zod2.z.enum(["get", "set", "clear"]).describe("'get': retrieve all cookies, 'set': add/update a cookie, 'clear': remove all cookies"),
882
+ cookie: import_zod2.z.object({
883
+ name: import_zod2.z.string(),
884
+ value: import_zod2.z.string(),
885
+ domain: import_zod2.z.string().optional(),
886
+ path: import_zod2.z.string().optional(),
887
+ httpOnly: import_zod2.z.boolean().optional(),
888
+ secure: import_zod2.z.boolean().optional()
889
+ }).optional().describe("Cookie data (required for 'set' action)")
890
+ },
891
+ ({ action, cookie }) => this.withLock(async () => {
892
+ const page = requirePage();
893
+ if (action === "get") {
894
+ const cookies = await page.cookies();
895
+ return { content: [{ type: "text", text: JSON.stringify(cookies, null, 2) }] };
896
+ } else if (action === "set") {
897
+ if (!cookie) throw new Error("cookie is required for set action");
898
+ await page.setCookie({ path: "/", ...cookie });
899
+ return { content: [{ type: "text", text: `Cookie set: ${cookie.name}` }] };
900
+ } else {
901
+ await page.clearCookies();
902
+ return { content: [{ type: "text", text: "All cookies cleared" }] };
903
+ }
904
+ })
905
+ );
906
+ server.tool(
907
+ "browser_storage",
908
+ "Read, write, or clear browser localStorage/sessionStorage. Useful for managing client-side state, authentication tokens, or application preferences.",
909
+ {
910
+ action: import_zod2.z.enum(["get", "set", "clear"]).describe("'get': read value(s), 'set': write a key-value pair, 'clear': remove all entries"),
911
+ kind: import_zod2.z.enum(["local", "session"]).optional().default("local").describe("'local' (persistent) or 'session' (cleared on tab close)"),
912
+ key: import_zod2.z.string().optional().describe("Storage key to get or set. Omit key with 'get' to retrieve all entries."),
913
+ value: import_zod2.z.string().optional().describe("Value to store (required for 'set' action)")
914
+ },
915
+ ({ action, kind, key, value }) => this.withLock(async () => {
916
+ const page = requirePage();
917
+ if (action === "get") {
918
+ const result = await page.storageGet(kind, key);
919
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
920
+ } else if (action === "set") {
921
+ if (!key || value === void 0) throw new Error("key and value are required for set action");
922
+ await page.storageSet(kind, key, value);
923
+ return { content: [{ type: "text", text: `Storage set: ${key}` }] };
924
+ } else {
925
+ await page.storageClear(kind);
926
+ return { content: [{ type: "text", text: `${kind}Storage cleared` }] };
927
+ }
928
+ })
929
+ );
930
+ server.tool(
931
+ "browser_dialog",
932
+ [
933
+ "Handle JavaScript dialogs (alert, confirm, prompt). Two-step pattern:",
934
+ " 1. action='arm' \u2014 register a one-shot handler (returns immediately, does NOT block).",
935
+ " 2. Trigger the dialog (e.g. browser_click on the button that calls confirm()).",
936
+ " 3. action='wait' \u2014 await the handler to confirm the dialog was handled.",
937
+ "",
938
+ "The 'accept' and 'promptText' params are only used with action='arm'."
939
+ ].join("\n"),
940
+ {
941
+ action: import_zod2.z.enum(["arm", "wait"]).describe(
942
+ "'arm' = register handler and return immediately; 'wait' = await the previously armed handler"
943
+ ),
944
+ accept: import_zod2.z.boolean().optional().default(true).describe(
945
+ "Accept (true) or dismiss (false) the dialog. Only used with action='arm'."
946
+ ),
947
+ promptText: import_zod2.z.string().optional().describe(
948
+ "Text to enter if the dialog is a prompt. Only used with action='arm'."
949
+ ),
950
+ timeoutMs: import_zod2.z.number().optional().describe(
951
+ "Timeout in ms for 'wait' action (default: 30000). Increase for slow-loading dialogs."
952
+ )
953
+ },
954
+ ({ action, accept, promptText, timeoutMs }) => this.withLock(async () => {
955
+ if (action === "arm") {
956
+ this.armedDialog = requirePage().armDialog({
957
+ accept: accept ?? true,
958
+ promptText,
959
+ timeoutMs
960
+ });
961
+ this.armedDialog.catch(() => {
962
+ });
963
+ return { content: [{ type: "text", text: "Dialog handler armed. Trigger the dialog now, then call browser_dialog with action='wait'." }] };
964
+ } else {
965
+ if (!this.armedDialog) {
966
+ return {
967
+ content: [{ type: "text", text: "No dialog handler is armed. Call browser_dialog with action='arm' first." }],
968
+ isError: true
969
+ };
970
+ }
971
+ const pending = this.armedDialog;
972
+ this.armedDialog = null;
973
+ await pending;
974
+ return { content: [{ type: "text", text: "Dialog handled successfully." }] };
975
+ }
412
976
  })
413
977
  );
414
978
  }
@@ -416,33 +980,33 @@ var BrowserTools = class {
416
980
 
417
981
  // src/tools/notebook.ts
418
982
  var import_zod3 = require("zod");
419
- var import_promises2 = __toESM(require("fs/promises"));
983
+ var import_promises3 = __toESM(require("fs/promises"));
420
984
  var import_child_process2 = require("child_process");
421
985
  var import_util2 = require("util");
422
986
  var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
423
987
  async function readNotebook(filePath) {
424
- const raw = await import_promises2.default.readFile(filePath, "utf-8");
988
+ const raw = await import_promises3.default.readFile(filePath, "utf-8");
425
989
  try {
426
990
  return JSON.parse(raw);
427
991
  } catch {
428
- throw new Error(`\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 Jupyter \uB178\uD2B8\uBD81 \uD30C\uC77C\uC785\uB2C8\uB2E4: ${filePath}`);
992
+ throw new Error(`Invalid Jupyter notebook file: ${filePath}`);
429
993
  }
430
994
  }
431
995
  async function writeNotebook(filePath, nb) {
432
- await import_promises2.default.writeFile(filePath, JSON.stringify(nb, null, 1), "utf-8");
996
+ await import_promises3.default.writeFile(filePath, JSON.stringify(nb, null, 1), "utf-8");
433
997
  }
434
998
  var NotebookTools = class {
435
999
  register(server) {
436
1000
  server.tool(
437
1001
  "notebook_read",
438
- ".ipynb \uB178\uD2B8\uBD81 \uC77D\uAE30",
439
- { path: import_zod3.z.string().describe("\uB178\uD2B8\uBD81 \uD30C\uC77C \uACBD\uB85C") },
1002
+ "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.",
1003
+ { path: import_zod3.z.string().describe("Path to the .ipynb notebook file") },
440
1004
  async ({ path: filePath }) => {
441
1005
  const nb = await readNotebook(filePath);
442
1006
  const cells = nb.cells.map((cell, i) => ({
443
1007
  index: i,
444
1008
  type: cell.cell_type,
445
- source: cell.source.join(""),
1009
+ source: Array.isArray(cell.source) ? cell.source.join("") : cell.source,
446
1010
  outputs: cell.outputs?.length ?? 0
447
1011
  }));
448
1012
  return {
@@ -452,30 +1016,35 @@ var NotebookTools = class {
452
1016
  );
453
1017
  server.tool(
454
1018
  "notebook_edit_cell",
455
- "\uB178\uD2B8\uBD81 \uD2B9\uC815 \uC140 \uC218\uC815",
1019
+ "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.",
456
1020
  {
457
- path: import_zod3.z.string(),
458
- cell_index: import_zod3.z.number().describe("0\uBD80\uD130 \uC2DC\uC791\uD558\uB294 \uC140 \uC778\uB371\uC2A4"),
459
- source: import_zod3.z.string().describe("\uC0C8 \uC18C\uC2A4 \uCF54\uB4DC")
1021
+ path: import_zod3.z.string().describe("Path to the .ipynb notebook file"),
1022
+ cell_index: import_zod3.z.number().describe("Cell index to edit (0-based). Use notebook_read to find the right index."),
1023
+ source: import_zod3.z.string().describe("New source code/content for the cell (replaces entire cell content)")
460
1024
  },
461
1025
  async ({ path: filePath, cell_index, source }) => {
462
1026
  const nb = await readNotebook(filePath);
463
1027
  if (cell_index < 0 || cell_index >= nb.cells.length) {
464
- throw new Error(`\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 \uC140 \uC778\uB371\uC2A4: ${cell_index}`);
1028
+ throw new Error(`Invalid cell index: ${cell_index}`);
465
1029
  }
466
1030
  nb.cells[cell_index].source = source.split("\n").map(
467
1031
  (l, i, arr) => i < arr.length - 1 ? l + "\n" : l
468
1032
  );
469
1033
  await writeNotebook(filePath, nb);
470
- return { content: [{ type: "text", text: "\uC140 \uC218\uC815 \uC644\uB8CC" }] };
1034
+ return { content: [{ type: "text", text: "Cell updated" }] };
471
1035
  }
472
1036
  );
473
1037
  server.tool(
474
1038
  "notebook_execute",
475
- "\uB178\uD2B8\uBD81 \uC2E4\uD589 (nbconvert --execute)",
1039
+ [
1040
+ "Execute all cells in a Jupyter notebook using nbconvert. Results are saved in-place \u2014 the notebook file is updated with execution outputs.",
1041
+ "",
1042
+ "Requires Jupyter to be installed (pip install jupyter). The timeout applies per cell, not for the entire notebook.",
1043
+ "If execution fails on a cell, the error is captured in the cell output and subsequent cells may not execute."
1044
+ ].join("\n"),
476
1045
  {
477
- path: import_zod3.z.string().describe("\uB178\uD2B8\uBD81 \uD30C\uC77C \uACBD\uB85C"),
478
- timeout: import_zod3.z.number().optional().default(300).describe("\uC140\uB2F9 \uD0C0\uC784\uC544\uC6C3 (\uCD08)")
1046
+ path: import_zod3.z.string().describe("Path to the .ipynb notebook file to execute"),
1047
+ timeout: import_zod3.z.number().optional().default(300).describe("Maximum execution time per cell in seconds (default: 300). Increase for cells with heavy computation.")
479
1048
  },
480
1049
  async ({ path: filePath, timeout }) => {
481
1050
  const nbconvertArgs = `nbconvert --to notebook --execute --inplace "${filePath}" --ExecutePreprocessor.timeout=${timeout}`;
@@ -491,7 +1060,7 @@ var NotebookTools = class {
491
1060
  for (const jupyter of candidates) {
492
1061
  try {
493
1062
  const { stdout, stderr } = await execAsync2(`${jupyter} ${nbconvertArgs}`);
494
- return { content: [{ type: "text", text: stdout || stderr || "\uC2E4\uD589 \uC644\uB8CC" }] };
1063
+ return { content: [{ type: "text", text: stdout || stderr || "Execution complete" }] };
495
1064
  } catch (err) {
496
1065
  const error = err;
497
1066
  if (error.code !== "127" && !error.message?.includes("not found") && !error.message?.includes("No such file")) {
@@ -499,17 +1068,17 @@ var NotebookTools = class {
499
1068
  }
500
1069
  }
501
1070
  }
502
- 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");
1071
+ throw new Error("jupyter not found. Install it and try again: pip install jupyter");
503
1072
  }
504
1073
  );
505
1074
  server.tool(
506
1075
  "notebook_add_cell",
507
- "\uB178\uD2B8\uBD81\uC5D0 \uC0C8 \uC140 \uCD94\uAC00",
1076
+ "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.",
508
1077
  {
509
- path: import_zod3.z.string().describe(".ipynb \uD30C\uC77C \uACBD\uB85C"),
510
- cell_type: import_zod3.z.enum(["code", "markdown"]).describe("\uC140 \uD0C0\uC785"),
511
- source: import_zod3.z.string().describe("\uC140 \uC18C\uC2A4 \uB0B4\uC6A9"),
512
- position: import_zod3.z.number().optional().describe("\uC0BD\uC785 \uC704\uCE58(0-based). \uC5C6\uC73C\uBA74 \uB9E8 \uB05D\uC5D0 \uCD94\uAC00")
1078
+ path: import_zod3.z.string().describe("Path to the .ipynb notebook file"),
1079
+ cell_type: import_zod3.z.enum(["code", "markdown"]).describe("'code' for executable cells, 'markdown' for text/documentation cells"),
1080
+ source: import_zod3.z.string().describe("Cell source content (Python code or Markdown text)"),
1081
+ 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.")
513
1082
  },
514
1083
  async ({ path: filePath, cell_type: cellType, source, position }) => {
515
1084
  const nb = await readNotebook(filePath);
@@ -528,31 +1097,31 @@ var NotebookTools = class {
528
1097
  } else if (position > nb.cells.length) {
529
1098
  nb.cells.push(newCell);
530
1099
  actualIndex = nb.cells.length - 1;
531
- warning = ` (\uACBD\uACE0: position ${position}\uC774 \uBC94\uC704\uB97C \uCD08\uACFC\uD558\uC5EC \uB05D(index: ${actualIndex})\uC5D0 \uCD94\uAC00\uB428)`;
1100
+ warning = ` (warning: position ${position} exceeded range, appended at end (index: ${actualIndex}))`;
532
1101
  } else {
533
1102
  const clamped = Math.max(0, position);
534
1103
  nb.cells.splice(clamped, 0, newCell);
535
1104
  actualIndex = clamped;
536
1105
  }
537
1106
  await writeNotebook(filePath, nb);
538
- return { content: [{ type: "text", text: `\uC140 \uCD94\uAC00 \uC644\uB8CC (index: ${actualIndex})${warning}` }] };
1107
+ return { content: [{ type: "text", text: `Cell added (index: ${actualIndex})${warning}` }] };
539
1108
  }
540
1109
  );
541
1110
  server.tool(
542
1111
  "notebook_delete_cell",
543
- "\uB178\uD2B8\uBD81 \uD2B9\uC815 \uC140 \uC0AD\uC81C",
1112
+ "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.",
544
1113
  {
545
- path: import_zod3.z.string().describe(".ipynb \uD30C\uC77C \uACBD\uB85C"),
546
- cell_index: import_zod3.z.number().describe("\uC0AD\uC81C\uD560 \uC140 \uC778\uB371\uC2A4 (0-based)")
1114
+ path: import_zod3.z.string().describe("Path to the .ipynb notebook file"),
1115
+ cell_index: import_zod3.z.number().describe("Cell index to delete (0-based). Use notebook_read first to verify content.")
547
1116
  },
548
1117
  async ({ path: filePath, cell_index }) => {
549
1118
  const nb = await readNotebook(filePath);
550
1119
  if (cell_index < 0 || cell_index >= nb.cells.length) {
551
- throw new Error(`\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 \uC140 \uC778\uB371\uC2A4: ${cell_index}`);
1120
+ throw new Error(`Invalid cell index: ${cell_index}`);
552
1121
  }
553
1122
  nb.cells.splice(cell_index, 1);
554
1123
  await writeNotebook(filePath, nb);
555
- return { content: [{ type: "text", text: `\uC140 \uC0AD\uC81C \uC644\uB8CC (index: ${cell_index})` }] };
1124
+ return { content: [{ type: "text", text: `Cell deleted (index: ${cell_index})` }] };
556
1125
  }
557
1126
  );
558
1127
  }
@@ -572,44 +1141,16 @@ function platform() {
572
1141
  }
573
1142
  var DeviceTools = class {
574
1143
  register(server) {
575
- server.tool(
576
- "screen_capture",
577
- "\uD654\uBA74 \uC2A4\uD06C\uB9B0\uC0F7 (OS \uB124\uC774\uD2F0\uBE0C)",
578
- {
579
- output_path: import_zod4.z.string().optional().describe("\uC800\uC7A5 \uACBD\uB85C (\uC5C6\uC73C\uBA74 temp \uC800\uC7A5 \uD6C4 base64 \uBC18\uD658)")
580
- },
581
- async ({ output_path }) => {
582
- const p = platform();
583
- const isTmp = !output_path;
584
- const tmpPath = output_path ?? `/tmp/junis_screen_${Date.now()}.png`;
585
- const cmd = {
586
- mac: `screencapture -x "${tmpPath}"`,
587
- win: `nircmd.exe savescreenshot "${tmpPath}"`,
588
- linux: `scrot "${tmpPath}"`
589
- }[p];
590
- try {
591
- await execAsync3(cmd);
592
- } catch (err) {
593
- throw new Error(`\uD654\uBA74 \uCEA1\uCC98 \uC2E4\uD328: ${err.message}`);
594
- }
595
- const { readFileSync, unlinkSync } = await import("fs");
596
- const data = readFileSync(tmpPath).toString("base64");
597
- if (isTmp) {
598
- try {
599
- unlinkSync(tmpPath);
600
- } catch {
601
- }
602
- }
603
- return {
604
- content: [{ type: "image", data, mimeType: "image/png" }]
605
- };
606
- }
607
- );
608
1144
  server.tool(
609
1145
  "camera_capture",
610
- "\uCE74\uBA54\uB77C \uC0AC\uC9C4 \uCD2C\uC601",
1146
+ [
1147
+ "Capture a photo from the device's camera and return it as base64 image data.",
1148
+ "",
1149
+ "Platform-specific: macOS (imagesnap), Windows (ffmpeg/dshow), Linux (fswebcam).",
1150
+ "Requires a connected camera with OS permissions granted. If output_path is provided, the file is also saved to disk."
1151
+ ].join("\n"),
611
1152
  {
612
- output_path: import_zod4.z.string().optional()
1153
+ 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).")
613
1154
  },
614
1155
  async ({ output_path }) => {
615
1156
  const p = platform();
@@ -625,10 +1166,10 @@ var DeviceTools = class {
625
1166
  } catch (err) {
626
1167
  const e = err;
627
1168
  return {
628
- content: [{ type: "text", text: `\u274C \uCE74\uBA54\uB77C\uB97C \uCC3E\uC744 \uC218 \uC5C6\uAC70\uB098 \uC811\uADFC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.
629
- \uC6D0\uC778: ${e.message}
1169
+ content: [{ type: "text", text: `\u274C Camera not found or inaccessible.
1170
+ Cause: ${e.message}
630
1171
 
631
- \uCE74\uBA54\uB77C\uAC00 \uC5F0\uACB0\uB418\uC5B4 \uC788\uB294\uC9C0 \uD655\uC778\uD558\uC138\uC694.` }],
1172
+ Please check if a camera is connected.` }],
632
1173
  isError: true
633
1174
  };
634
1175
  }
@@ -645,10 +1186,10 @@ var DeviceTools = class {
645
1186
  );
646
1187
  server.tool(
647
1188
  "notification_send",
648
- "OS \uC54C\uB9BC \uC804\uC1A1",
1189
+ "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.",
649
1190
  {
650
- title: import_zod4.z.string().describe("\uC54C\uB9BC \uC81C\uBAA9"),
651
- message: import_zod4.z.string().describe("\uC54C\uB9BC \uB0B4\uC6A9")
1191
+ title: import_zod4.z.string().describe("Notification title (displayed prominently)"),
1192
+ message: import_zod4.z.string().describe("Notification body text")
652
1193
  },
653
1194
  async ({ title, message }) => {
654
1195
  try {
@@ -661,10 +1202,10 @@ var DeviceTools = class {
661
1202
  }
662
1203
  );
663
1204
  });
664
- return { content: [{ type: "text", text: "\uC54C\uB9BC \uC804\uC1A1 \uC644\uB8CC" }] };
1205
+ return { content: [{ type: "text", text: "Notification sent" }] };
665
1206
  } catch (err) {
666
1207
  return {
667
- content: [{ type: "text", text: `\uC54C\uB9BC \uC804\uC1A1 \uC2E4\uD328: ${err.message}` }],
1208
+ content: [{ type: "text", text: `Notification failed: ${err.message}` }],
668
1209
  isError: true
669
1210
  };
670
1211
  }
@@ -672,7 +1213,7 @@ var DeviceTools = class {
672
1213
  );
673
1214
  server.tool(
674
1215
  "clipboard_read",
675
- "\uD074\uB9BD\uBCF4\uB4DC \uC77D\uAE30",
1216
+ "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).",
676
1217
  {},
677
1218
  async () => {
678
1219
  const p = platform();
@@ -683,8 +1224,10 @@ var DeviceTools = class {
683
1224
  );
684
1225
  server.tool(
685
1226
  "clipboard_write",
686
- "\uD074\uB9BD\uBCF4\uB4DC \uC4F0\uAE30",
687
- { text: import_zod4.z.string() },
1227
+ "Write text to the system clipboard, replacing its current contents. Use to prepare content for the user to paste elsewhere.",
1228
+ {
1229
+ text: import_zod4.z.string().describe("Text to copy to the clipboard")
1230
+ },
688
1231
  async ({ text }) => {
689
1232
  const p = platform();
690
1233
  const cmd = {
@@ -693,21 +1236,26 @@ var DeviceTools = class {
693
1236
  linux: `echo "${text}" | xclip -selection clipboard`
694
1237
  }[p];
695
1238
  await execAsync3(cmd);
696
- return { content: [{ type: "text", text: "\uD074\uB9BD\uBCF4\uB4DC \uC800\uC7A5 \uC644\uB8CC" }] };
1239
+ return { content: [{ type: "text", text: "Saved to clipboard" }] };
697
1240
  }
698
1241
  );
699
1242
  server.tool(
700
1243
  "screen_record",
701
- "\uD654\uBA74 \uB179\uD654 \uC2DC\uC791/\uC911\uC9C0 (macOS: screencapture -v, \uAE30\uD0C0: ffmpeg)",
1244
+ [
1245
+ "Start or stop screen recording. Captures the full screen as MP4 video.",
1246
+ "",
1247
+ "Use action='start' to begin, action='stop' to end and save. Only one recording can be active at a time.",
1248
+ "Platform-specific: macOS (screencapture -v), Windows/Linux (ffmpeg)."
1249
+ ].join("\n"),
702
1250
  {
703
- action: import_zod4.z.enum(["start", "stop"]).describe("start: \uB179\uD654 \uC2DC\uC791, stop: \uB179\uD654 \uC911\uC9C0"),
704
- output_path: import_zod4.z.string().optional().describe("\uC800\uC7A5 \uACBD\uB85C (start \uC2DC \uC0AC\uC6A9, \uAE30\uBCF8: /tmp/junis_record_<timestamp>.mp4)")
1251
+ action: import_zod4.z.enum(["start", "stop"]).describe("'start': begin recording, 'stop': end recording and save the file"),
1252
+ output_path: import_zod4.z.string().optional().describe("Output file path (used with 'start'). Default: /tmp/junis_record_<timestamp>.mp4")
705
1253
  },
706
1254
  async ({ action, output_path }) => {
707
1255
  const p = platform();
708
1256
  if (action === "start") {
709
1257
  if (screenRecordPid) {
710
- return { content: [{ type: "text", text: "\uC774\uBBF8 \uB179\uD654 \uC911\uC785\uB2C8\uB2E4." }] };
1258
+ return { content: [{ type: "text", text: "Already recording." }] };
711
1259
  }
712
1260
  const tmpPath = output_path ?? `/tmp/junis_record_${Date.now()}.mp4`;
713
1261
  const { spawn } = await import("child_process");
@@ -715,10 +1263,10 @@ var DeviceTools = class {
715
1263
  const child = spawn(cmd[0], cmd[1], { detached: true, stdio: "ignore" });
716
1264
  child.unref();
717
1265
  screenRecordPid = child.pid ?? null;
718
- return { content: [{ type: "text", text: `\uB179\uD654 \uC2DC\uC791\uB428. \uC800\uC7A5 \uACBD\uB85C: ${tmpPath} (PID: ${screenRecordPid})` }] };
1266
+ return { content: [{ type: "text", text: `Recording started. Output path: ${tmpPath} (PID: ${screenRecordPid})` }] };
719
1267
  } else {
720
1268
  if (!screenRecordPid) {
721
- return { content: [{ type: "text", text: "\uD604\uC7AC \uB179\uD654 \uC911\uC774 \uC544\uB2D9\uB2C8\uB2E4." }] };
1269
+ return { content: [{ type: "text", text: "Not currently recording." }] };
722
1270
  }
723
1271
  try {
724
1272
  process.kill(screenRecordPid, "SIGINT");
@@ -726,13 +1274,18 @@ var DeviceTools = class {
726
1274
  } catch {
727
1275
  }
728
1276
  screenRecordPid = null;
729
- return { content: [{ type: "text", text: "\uB179\uD654 \uC911\uC9C0\uB428." }] };
1277
+ return { content: [{ type: "text", text: "Recording stopped." }] };
730
1278
  }
731
1279
  }
732
1280
  );
733
1281
  server.tool(
734
1282
  "location_get",
735
- "\uD604\uC7AC \uC704\uCE58 \uC870\uD68C (macOS: CoreLocation CLI, \uAE30\uD0C0: IP \uAE30\uBC18 fallback)",
1283
+ [
1284
+ "Get the device's current geographic location.",
1285
+ "",
1286
+ "macOS: Uses CoreLocation (GPS-accurate) with IP-based fallback. Other platforms: IP-based geolocation (city-level accuracy only).",
1287
+ "Returns latitude, longitude, and (when available) city and country."
1288
+ ].join("\n"),
736
1289
  {},
737
1290
  async () => {
738
1291
  const p = platform();
@@ -740,28 +1293,28 @@ var DeviceTools = class {
740
1293
  try {
741
1294
  const { stdout } = await execAsync3("CoreLocationCLI -once -format '%latitude,%longitude'", { timeout: 1e4 });
742
1295
  const [lat, lon] = stdout.trim().split(",");
743
- return { content: [{ type: "text", text: `\uC704\uB3C4: ${lat}, \uACBD\uB3C4: ${lon}` }] };
1296
+ return { content: [{ type: "text", text: `Latitude: ${lat}, Longitude: ${lon}` }] };
744
1297
  } catch {
745
1298
  }
746
1299
  }
747
1300
  const res = await fetch("http://ip-api.com/json/");
748
1301
  const data = await res.json();
749
1302
  if (data.status !== "success") {
750
- throw new Error(`IP \uC704\uCE58 \uC870\uD68C \uC2E4\uD328: ${data.message ?? data.status}`);
1303
+ throw new Error(`IP location lookup failed: ${data.message ?? data.status}`);
751
1304
  }
752
1305
  return {
753
1306
  content: [{
754
1307
  type: "text",
755
- text: `\uC704\uB3C4: ${data.lat}, \uACBD\uB3C4: ${data.lon}, \uB3C4\uC2DC: ${data.city}, \uAD6D\uAC00: ${data.country} (IP \uAE30\uBC18 \uCD94\uC815)`
1308
+ text: `Latitude: ${data.lat}, Longitude: ${data.lon}, City: ${data.city}, Country: ${data.country} (estimated via IP)`
756
1309
  }]
757
1310
  };
758
1311
  }
759
1312
  );
760
1313
  server.tool(
761
1314
  "audio_play",
762
- "\uC624\uB514\uC624 \uD30C\uC77C \uC7AC\uC0DD (macOS: afplay, \uAE30\uD0C0: ffplay)",
1315
+ "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).",
763
1316
  {
764
- file_path: import_zod4.z.string().describe("\uC7AC\uC0DD\uD560 \uC624\uB514\uC624 \uD30C\uC77C \uACBD\uB85C")
1317
+ file_path: import_zod4.z.string().describe("Absolute path to the audio file to play")
765
1318
  },
766
1319
  async ({ file_path }) => {
767
1320
  const p = platform();
@@ -771,14 +1324,14 @@ var DeviceTools = class {
771
1324
  linux: `ffplay -nodisp -autoexit "${file_path}"`
772
1325
  }[p];
773
1326
  await execAsync3(cmd);
774
- return { content: [{ type: "text", text: `\uC7AC\uC0DD \uC644\uB8CC: ${file_path}` }] };
1327
+ return { content: [{ type: "text", text: `Playback complete: ${file_path}` }] };
775
1328
  }
776
1329
  );
777
1330
  }
778
1331
  };
779
1332
 
780
1333
  // src/server/stdio.ts
781
- async function main() {
1334
+ async function startStdioServer() {
782
1335
  const server = new import_mcp.McpServer({ name: "junis", version: "0.1.0" });
783
1336
  const fsTools = new FilesystemTools();
784
1337
  fsTools.register(server);
@@ -796,4 +1349,10 @@ async function main() {
796
1349
  process.exit(0);
797
1350
  });
798
1351
  }
799
- main().catch(console.error);
1352
+ if (require.main === module) {
1353
+ startStdioServer().catch(console.error);
1354
+ }
1355
+ // Annotate the CommonJS export names for ESM import in node:
1356
+ 0 && (module.exports = {
1357
+ startStdioServer
1358
+ });