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.
- package/README.md +17 -4
- package/dist/cli/index.js +1305 -325
- package/dist/server/mcp.js +1102 -209
- package/dist/server/stdio.js +766 -207
- package/package.json +4 -2
package/dist/server/stdio.js
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
46
|
-
timeout_ms: import_zod.z.number().optional().default(3e4).describe("
|
|
47
|
-
background: import_zod.z.boolean().optional().default(false).describe("
|
|
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: "
|
|
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 || "(
|
|
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:
|
|
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
|
-
|
|
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("
|
|
81
|
-
encoding: import_zod.z.enum(["utf-8", "base64"]).optional().default("utf-8").describe("
|
|
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
|
|
172
|
+
return { content: [{ type: "text", text: `\u274C File not found: ${filePath}` }], isError: true };
|
|
91
173
|
}
|
|
92
|
-
return { content: [{ type: "text", text: `\u274C
|
|
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
|
-
|
|
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("
|
|
101
|
-
content: import_zod.z.string().describe("
|
|
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: "
|
|
194
|
+
return { content: [{ type: "text", text: "File saved" }] };
|
|
107
195
|
}
|
|
108
196
|
);
|
|
109
197
|
server.tool(
|
|
110
198
|
"list_directory",
|
|
111
|
-
|
|
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("
|
|
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
|
|
214
|
+
return { content: [{ type: "text", text: `\u274C Directory not found: ${dirPath}` }], isError: true };
|
|
124
215
|
}
|
|
125
|
-
return { content: [{ type: "text", text: `\u274C
|
|
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
|
-
|
|
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("
|
|
134
|
-
directory: import_zod.z.string().optional().default(".").describe("
|
|
135
|
-
file_pattern: import_zod.z.string().optional().default("**/*").describe("
|
|
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 || "
|
|
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") || "
|
|
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
|
-
"
|
|
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
|
-
|
|
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("
|
|
186
|
-
signal: import_zod.z.enum(["SIGTERM", "SIGKILL"]).optional().default("SIGTERM").describe("
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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("
|
|
235
|
-
old_string: import_zod.z.string().describe("
|
|
236
|
-
new_string: import_zod.z.string().describe("
|
|
237
|
-
replace_all: import_zod.z.boolean().optional().default(false).describe("true
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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?.
|
|
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("
|
|
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
|
|
311
|
-
{
|
|
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
|
-
|
|
314
|
-
|
|
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: [{
|
|
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
|
-
"\
|
|
323
|
-
{
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
334
|
-
text: import_zod2.z.string().describe("
|
|
335
|
-
|
|
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
|
-
({
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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("
|
|
349
|
-
|
|
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,
|
|
352
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
"
|
|
373
|
-
"
|
|
374
|
-
{
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
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
|
|
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(
|
|
992
|
+
throw new Error(`Invalid Jupyter notebook file: ${filePath}`);
|
|
429
993
|
}
|
|
430
994
|
}
|
|
431
995
|
async function writeNotebook(filePath, nb) {
|
|
432
|
-
await
|
|
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
|
|
439
|
-
{ path: import_zod3.z.string().describe("
|
|
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
|
-
"
|
|
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
|
|
459
|
-
source: import_zod3.z.string().describe("
|
|
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(
|
|
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: "
|
|
1034
|
+
return { content: [{ type: "text", text: "Cell updated" }] };
|
|
471
1035
|
}
|
|
472
1036
|
);
|
|
473
1037
|
server.tool(
|
|
474
1038
|
"notebook_execute",
|
|
475
|
-
|
|
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("
|
|
478
|
-
timeout: import_zod3.z.number().optional().default(300).describe("
|
|
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 || "
|
|
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
|
|
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
|
-
"
|
|
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
|
|
510
|
-
cell_type: import_zod3.z.enum(["code", "markdown"]).describe("
|
|
511
|
-
source: import_zod3.z.string().describe("
|
|
512
|
-
position: import_zod3.z.number().optional().describe("
|
|
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 = ` (
|
|
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:
|
|
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
|
-
"
|
|
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
|
|
546
|
-
cell_index: import_zod3.z.number().describe("
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
|
629
|
-
|
|
1169
|
+
content: [{ type: "text", text: `\u274C Camera not found or inaccessible.
|
|
1170
|
+
Cause: ${e.message}
|
|
630
1171
|
|
|
631
|
-
|
|
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
|
|
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("
|
|
651
|
-
message: import_zod4.z.string().describe("
|
|
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: "
|
|
1205
|
+
return { content: [{ type: "text", text: "Notification sent" }] };
|
|
665
1206
|
} catch (err) {
|
|
666
1207
|
return {
|
|
667
|
-
content: [{ type: "text", text:
|
|
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
|
-
"
|
|
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
|
-
"
|
|
687
|
-
{
|
|
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: "
|
|
1239
|
+
return { content: [{ type: "text", text: "Saved to clipboard" }] };
|
|
697
1240
|
}
|
|
698
1241
|
);
|
|
699
1242
|
server.tool(
|
|
700
1243
|
"screen_record",
|
|
701
|
-
|
|
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:
|
|
704
|
-
output_path: import_zod4.z.string().optional().describe("
|
|
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: "
|
|
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:
|
|
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: "
|
|
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: "
|
|
1277
|
+
return { content: [{ type: "text", text: "Recording stopped." }] };
|
|
730
1278
|
}
|
|
731
1279
|
}
|
|
732
1280
|
);
|
|
733
1281
|
server.tool(
|
|
734
1282
|
"location_get",
|
|
735
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
"
|
|
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("
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|