qz-browser-agent 0.1.3 → 0.1.5
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 +54 -25
- package/package.json +5 -2
- package/src/args.mjs +57 -16
- package/src/bin.mjs +2 -2
- package/src/catalog.json +73 -10
- package/src/cli-resources.mjs +2 -2
- package/src/cli.mjs +135 -120
- package/src/client.mjs +61 -19
- package/src/commands.mjs +45 -10
- package/src/connect-page.mjs +89 -0
- package/src/connection.mjs +145 -0
- package/src/daemon.mjs +180 -31
- package/src/drive-guide.mjs +38 -0
- package/src/drive-timeout.mjs +21 -0
- package/src/open-browser.mjs +40 -11
- package/src/qz-drive-SKILL.md +165 -0
- package/src/qz-logo-day.png +0 -0
- package/src/qz-logo.png +0 -0
- package/src/script-call.mjs +40 -0
- package/src/server.mjs +25 -8
- package/src/state.mjs +81 -29
- package/src/task-stream.mjs +4 -4
- package/src/terminal.mjs +68 -18
- package/src/tools.mjs +148 -30
package/src/terminal.mjs
CHANGED
|
@@ -198,6 +198,29 @@ export function renderInputNotice(input, options = {}) {
|
|
|
198
198
|
return out.finish();
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
function formatUsage(usage) {
|
|
202
|
+
if (!usage || typeof usage !== "object") return "";
|
|
203
|
+
const count = value => {
|
|
204
|
+
const number = Number(value);
|
|
205
|
+
return Number.isFinite(number) ? String(Math.max(0, Math.trunc(number))) : "0";
|
|
206
|
+
};
|
|
207
|
+
const cost = Number(usage.costUsd);
|
|
208
|
+
const text = Number.isFinite(cost) ? cost.toFixed(6).replace(/\.?0+$/u, "") : "";
|
|
209
|
+
const shown = ["inputTokens", "outputTokens", "cachedTokens", "costUsd", "modelCalls", "toolCalls", "screenshots", "contextCompressions"]
|
|
210
|
+
.some(key => Number(usage[key]) > 0);
|
|
211
|
+
if (!shown) return "";
|
|
212
|
+
return [
|
|
213
|
+
`input ${count(usage.inputTokens)}`,
|
|
214
|
+
`output ${count(usage.outputTokens)}`,
|
|
215
|
+
`cached ${count(usage.cachedTokens)}`,
|
|
216
|
+
`cost $${text || "0"}`,
|
|
217
|
+
`model calls ${count(usage.modelCalls)}`,
|
|
218
|
+
`tool calls ${count(usage.toolCalls)}`,
|
|
219
|
+
`screenshots ${count(usage.screenshots)}`,
|
|
220
|
+
`compressions ${count(usage.contextCompressions)}`,
|
|
221
|
+
].join(", ");
|
|
222
|
+
}
|
|
223
|
+
|
|
201
224
|
function snapshotView(out, snapshot, { streamed = false, clientId = "" } = {}) {
|
|
202
225
|
const status = taskStatus(snapshot);
|
|
203
226
|
out.line(label(status), tone(status));
|
|
@@ -212,6 +235,8 @@ function snapshotView(out, snapshot, { streamed = false, clientId = "" } = {}) {
|
|
|
212
235
|
if (response && !streamed) {
|
|
213
236
|
out.line(); out.line("Latest response", "muted"); out.line(response);
|
|
214
237
|
}
|
|
238
|
+
const usage = formatUsage(snapshot.usage);
|
|
239
|
+
if (usage) out.field("Usage", usage);
|
|
215
240
|
}
|
|
216
241
|
|
|
217
242
|
function taskList(out, sessions, client = "") {
|
|
@@ -412,13 +437,14 @@ export function renderResult(parsed, result, options = {}) {
|
|
|
412
437
|
if (action === "list" || !action) taskList(out, result?.sessions || [], client);
|
|
413
438
|
else if (action === "get") snapshotView(out, result || {}, presentation);
|
|
414
439
|
else if (action === "create") {
|
|
440
|
+
const sessionId = result?.session?.sessionId || result?.sessionId || result?.session?.id;
|
|
415
441
|
if (result?.snapshot) snapshotView(out, result.snapshot, presentation);
|
|
416
442
|
else {
|
|
417
443
|
out.line(result?.started ? "Task queued" : "Task created", "accent");
|
|
418
|
-
out.field("Task",
|
|
444
|
+
out.field("Task", sessionId);
|
|
419
445
|
out.field("Title", single(result?.session?.title));
|
|
420
446
|
}
|
|
421
|
-
taskCommands(out,
|
|
447
|
+
taskCommands(out, sessionId || result?.snapshot?.sessionId, { client, cancel: !result?.snapshot || ["pending", "running", "waiting_user", "waitingUser", "paused"].includes(taskStatus(result.snapshot)) });
|
|
422
448
|
} else if (action === "stop") {
|
|
423
449
|
out.line(result?.stopped ? "Task stopped" : "Task has no active run", "accent");
|
|
424
450
|
out.field("Task", result?.sessionId || parsed.id);
|
|
@@ -456,16 +482,19 @@ export function renderResult(parsed, result, options = {}) {
|
|
|
456
482
|
out.line("Service upgrade required for browser pairing.", "waiting");
|
|
457
483
|
out.line(result.hint);
|
|
458
484
|
} else if (result?.updateAvailable) {
|
|
459
|
-
out.line("Service update available.
|
|
460
|
-
|
|
461
|
-
|
|
485
|
+
out.line("Service update available. Compatible connections remain in use.", "waiting");
|
|
486
|
+
} else if (!result?.connected) out.command("qz connect uri");
|
|
487
|
+
} else if (command === "cleanup") {
|
|
488
|
+
out.line(result?.stopped || result?.removedFiles ? "Cleanup complete" : "Nothing to clean", "accent");
|
|
489
|
+
out.field("Services stopped", result?.stopped ?? 0);
|
|
490
|
+
out.field("Runtime files removed", result?.removedFiles ?? 0);
|
|
491
|
+
for (const service of result?.services || []) {
|
|
492
|
+
if (service.status === "stopped") out.field(`Stopped PID ${service.processId}`, service.endpoint || service.scopeId);
|
|
493
|
+
}
|
|
462
494
|
} else if (command === "install") {
|
|
463
495
|
out.line(result?.opened ? "Chrome Web Store opened" : "Install qz from the Chrome Web Store", "accent");
|
|
464
496
|
if (!result?.opened) out.line(result?.url);
|
|
465
|
-
out.line("Click Add to Chrome, then
|
|
466
|
-
} else if (command === "stop") {
|
|
467
|
-
out.line(result?.stopped ? "Local service stopped" : "Local service is already stopped", "accent");
|
|
468
|
-
out.command("qz connect");
|
|
497
|
+
out.line("Click Add to Chrome, then create a session:"); out.command('qz session create --title "Browser task"');
|
|
469
498
|
} else {
|
|
470
499
|
out.line(JSON.stringify(result ?? null, null, 2));
|
|
471
500
|
}
|
|
@@ -480,13 +509,18 @@ export function renderHelp(topic = "", options = {}) {
|
|
|
480
509
|
for (const detail of details) { out.line(); out.line(detail, detail.startsWith("qz ") ? "accent" : undefined); }
|
|
481
510
|
} else {
|
|
482
511
|
out.line("qz · Your browser, from the terminal", "accent");
|
|
483
|
-
out.line("
|
|
512
|
+
out.line("Drive your browser with sessions and scripts, or delegate a task to the built-in agent.");
|
|
484
513
|
out.line();
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
514
|
+
const entries = Object.entries(COMMANDS);
|
|
515
|
+
for (const direct of [true, false]) {
|
|
516
|
+
out.line(direct ? "Browser scripts" : "Connection, built-in agent and saved results", "accent");
|
|
517
|
+
for (const [command, { help, summary: description }] of entries) {
|
|
518
|
+
if (!help || (["session", "script"].includes(command)) !== direct) continue;
|
|
519
|
+
const name = help[0].replace(/^qz /, "").replace(/ \[.*$/, "");
|
|
520
|
+
if (out.width >= 64) out.line(` ${pad(name, 23)} ${description}`);
|
|
521
|
+
else { out.command(`qz ${name}`); out.line(` ${description}`, "muted"); }
|
|
522
|
+
}
|
|
523
|
+
out.line();
|
|
490
524
|
}
|
|
491
525
|
out.line(); out.command('qz create "Summarize this page." --stream');
|
|
492
526
|
out.command('qz create "Compare these tabs." --title Research --wait');
|
|
@@ -503,15 +537,31 @@ export function renderHelp(topic = "", options = {}) {
|
|
|
503
537
|
out.line("Version: qz --version (-v)", "muted");
|
|
504
538
|
out.line("npm: npm run qz -- COMMAND [options]", "muted");
|
|
505
539
|
out.shellNote();
|
|
540
|
+
if (!topic) {
|
|
541
|
+
out.line();
|
|
542
|
+
out.line("Full browser guide: qz skill", "muted");
|
|
543
|
+
}
|
|
506
544
|
return out.finish();
|
|
507
545
|
}
|
|
508
546
|
|
|
509
547
|
export function renderError(payload, options = {}) {
|
|
510
548
|
const out = output(options);
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
-
|
|
549
|
+
const value = payload?.error || payload || {};
|
|
550
|
+
const error = { ...value.details, ...value };
|
|
551
|
+
const waiting = error.code === "CONNECTION_PENDING";
|
|
552
|
+
out.line(waiting ? "Waiting for browser extension" : "qz error", waiting ? "waiting" : "error");
|
|
514
553
|
out.line(error.message || "The command could not be completed.");
|
|
554
|
+
if (error.code === "CLEANUP_INCOMPLETE") {
|
|
555
|
+
for (const service of error.services || []) {
|
|
556
|
+
if (service.status === "skipped") out.field(service.endpoint || service.scopeId, service.message || service.code);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (waiting || error.code === "CLIENT_OFFLINE") {
|
|
560
|
+
out.field("Stage", error.stage);
|
|
561
|
+
out.field("Browser", error.selectedBrowser?.name);
|
|
562
|
+
out.field("UUID", error.selectedBrowser?.clientId);
|
|
563
|
+
if (Array.isArray(error.attemptedBrowsers)) out.field("Browsers tried", error.attemptedBrowsers.slice(0, 3).join(", ") || "None");
|
|
564
|
+
}
|
|
515
565
|
if (error.url) { out.line(); out.line(error.url, "accent"); }
|
|
516
566
|
if (error.hint) { out.line(); out.line(error.hint); }
|
|
517
567
|
if (error.command) out.script(error.command);
|
package/src/tools.mjs
CHANGED
|
@@ -5,6 +5,9 @@ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv
|
|
|
5
5
|
import { BridgeError } from "./state.mjs";
|
|
6
6
|
import { waitForTask } from "./task-stream.mjs";
|
|
7
7
|
import { PACKAGE_VERSION } from "./package-info.mjs";
|
|
8
|
+
import { driveGuide } from "./drive-guide.mjs";
|
|
9
|
+
import { driveExtensionTimeoutSeconds } from "./drive-timeout.mjs";
|
|
10
|
+
export { driveExtensionTimeoutSeconds } from "./drive-timeout.mjs";
|
|
8
11
|
|
|
9
12
|
// Public MCP tools use the extension's version 1 wire protocol.
|
|
10
13
|
export const TOOL_CATALOG = JSON.parse(readFileSync(new URL("./catalog.json", import.meta.url), "utf8"))
|
|
@@ -50,7 +53,7 @@ function argumentsWithDefaults(definition, input) {
|
|
|
50
53
|
}));
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
export async function executeMcpTool(state, name, input = {}, { signal, onTaskUpdate } = {}) {
|
|
56
|
+
export async function executeMcpTool(state, name, input = {}, { signal, onTaskUpdate, clientName } = {}) {
|
|
54
57
|
const args = validateMcpToolInput(name, input);
|
|
55
58
|
if (name === "qingzhou_bridge_status") return state.status();
|
|
56
59
|
if (name === "qingzhou_forget_browser") return state.forgetBrowser(args.clientId);
|
|
@@ -62,12 +65,12 @@ export async function executeMcpTool(state, name, input = {}, { signal, onTaskUp
|
|
|
62
65
|
const connectionSignal = state.signal(connection.controller.signal);
|
|
63
66
|
const operationSignal = signal ? AbortSignal.any([connectionSignal, signal]) : connectionSignal;
|
|
64
67
|
operationSignal.throwIfAborted();
|
|
65
|
-
const result = await executeBrowserTool(state, name, args, { signal: operationSignal, connectionSignal, onTaskUpdate });
|
|
68
|
+
const result = await executeBrowserTool(state, name, args, { signal: operationSignal, connectionSignal, onTaskUpdate, clientName });
|
|
66
69
|
return args.clientId && (observableTools.has(name) || taskInputResults.has(name) || ["qingzhou_list_sessions", "qingzhou_get_user_input", "qingzhou_stop_session", "qingzhou_abort_session"].includes(name)) && result && typeof result === "object" && !Array.isArray(result)
|
|
67
70
|
? { ...result, clientId: args.clientId } : result;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
async function executeBrowserTool(state, name, args, { signal, connectionSignal, onTaskUpdate }) {
|
|
73
|
+
async function executeBrowserTool(state, name, args, { signal, connectionSignal, onTaskUpdate, clientName }) {
|
|
71
74
|
const observe = observableTools.has(name) && (name === "qingzhou_watch_task" || args.wait || args.stream);
|
|
72
75
|
const asyncTool = name === "qingzhou_execute_tool" && args.wait === false;
|
|
73
76
|
const options = { timeoutSeconds: args.timeoutSeconds, clientId: args.clientId, signal };
|
|
@@ -88,21 +91,24 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
|
|
|
88
91
|
});
|
|
89
92
|
} catch (cause) {
|
|
90
93
|
const code = typeof cause.code === "string" ? cause.code
|
|
91
|
-
: cause.name === "TimeoutError" ? "
|
|
94
|
+
: cause.name === "TimeoutError" ? "TIMEOUT" : cause.name === "AbortError" ? "INTERRUPTED" : "TASK_WAIT_FAILED";
|
|
92
95
|
const error = Object.assign(new Error(cause.message, { cause }), { code });
|
|
93
96
|
error.details = { ...cause.details, sessionId, clientId: args.clientId,
|
|
94
|
-
recovery: code === "
|
|
97
|
+
recovery: code === "TASK_DELETED"
|
|
95
98
|
? { tool: "qingzhou_list_sessions", arguments: { clientId: args.clientId } }
|
|
96
|
-
: { tool: code === "
|
|
99
|
+
: { tool: code === "TASK_CHANGED" ? "qingzhou_get_session_snapshot" : "qingzhou_watch_task",
|
|
97
100
|
arguments: { sessionId, clientId: args.clientId } } };
|
|
98
101
|
throw error;
|
|
99
102
|
}
|
|
100
103
|
};
|
|
101
104
|
if (name === "qingzhou_watch_task") return { sessionId: args.sessionId, snapshot: await watch(args.sessionId, { allowIdle: true }) };
|
|
102
105
|
if (name === "qingzhou_get_tool") {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
const tool = await state.callExtension("get_tool", {
|
|
107
|
+
sessionId: args.sessionId, name: args.name, timeoutSeconds: args.timeoutSeconds,
|
|
108
|
+
}, options);
|
|
109
|
+
if (!tool || typeof tool !== "object") {
|
|
110
|
+
throw new BridgeError("TOOL_NOT_FOUND", `No browser tool named ${args.name}. Use qingzhou_list_tools to list available tools.`);
|
|
111
|
+
}
|
|
106
112
|
return tool;
|
|
107
113
|
}
|
|
108
114
|
if (name === "qingzhou_call") return state.callExtension(args.method.trim(), args.params || {}, options);
|
|
@@ -116,11 +122,35 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
|
|
|
116
122
|
}
|
|
117
123
|
if (name === "qingzhou_follow_up") params.images ||= [];
|
|
118
124
|
if (name === "qingzhou_submit_wait_form") params.answers ||= {};
|
|
125
|
+
if (name === "qingzhou_create_drive_session") {
|
|
126
|
+
const displayName = String(clientName || "").trim();
|
|
127
|
+
if (displayName) params.clientName = displayName;
|
|
128
|
+
}
|
|
119
129
|
if (name === "qingzhou_execute_tool") {
|
|
130
|
+
const scriptApiVersion = params.scriptApiVersion;
|
|
131
|
+
if (scriptApiVersion != null) {
|
|
132
|
+
const capabilities = await state.callExtension("capabilities", {}, options);
|
|
133
|
+
if (capabilities?.scriptApiVersion !== scriptApiVersion) {
|
|
134
|
+
throw Object.assign(new BridgeError("UPGRADE_REQUIRED", "The connected extension does not support this script API. No script was executed."), {
|
|
135
|
+
hint: "Update the CLI and extension together, then reconnect before running the script.",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
120
139
|
delete params.wait;
|
|
121
|
-
params.args ||= {};
|
|
122
|
-
|
|
123
|
-
|
|
140
|
+
if (!Array.isArray(params.calls)) params.args ||= {};
|
|
141
|
+
const displayName = String(clientName || "").trim();
|
|
142
|
+
if (displayName) params.clientName = displayName;
|
|
143
|
+
const requestTimeout = driveExtensionTimeoutSeconds(params, args.timeoutSeconds || state.requestTimeoutSeconds || 180);
|
|
144
|
+
const callOptions = { ...options, timeoutSeconds: requestTimeout, cancelOnAbort: !asyncTool };
|
|
145
|
+
const invoke = () => asyncTool
|
|
146
|
+
? { ...state.startExtensionJob("execute_tool", params, { ...callOptions, connectionSignal }), sessionId: args.sessionId, clientId }
|
|
147
|
+
: state.callExtension("execute_tool", params, callOptions);
|
|
148
|
+
try {
|
|
149
|
+
return await invoke();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error?.code === "DRIVE_SESSION_BUSY") return await invoke();
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
124
154
|
}
|
|
125
155
|
const method = name === "qingzhou_abort_session" ? "stop_session" : name.slice("qingzhou_".length);
|
|
126
156
|
let result = await state.callExtension(method, params, options);
|
|
@@ -133,14 +163,15 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
|
|
|
133
163
|
}
|
|
134
164
|
result = bindInputClient(name, result, clientId);
|
|
135
165
|
if (observe) {
|
|
136
|
-
const sessionId = name === "qingzhou_create_session"
|
|
166
|
+
const sessionId = name === "qingzhou_create_session"
|
|
167
|
+
? result?.session?.sessionId || result?.sessionId || result?.session?.id : args.sessionId;
|
|
137
168
|
if (name === "qingzhou_create_session") {
|
|
138
|
-
if (!sessionId) throw new BridgeError("
|
|
169
|
+
if (!sessionId) throw new BridgeError("INVALID_RESPONSE", "The task was created without an ID. Use qingzhou_list_sessions to find it before retrying.");
|
|
139
170
|
await onTaskUpdate?.({ type: "created", sessionId, clientId, ...result });
|
|
140
171
|
result = { ...result, snapshot: await watch(sessionId, { allowIdle: !args.firstMessage.trim() }) };
|
|
141
172
|
} else if (name === "qingzhou_send_message" && result?.accepted === true) {
|
|
142
173
|
if (typeof result.clientMessageId !== "string" || !result.clientMessageId.trim()) {
|
|
143
|
-
throw Object.assign(new BridgeError("
|
|
174
|
+
throw Object.assign(new BridgeError("INVALID_RESPONSE", "The message was accepted without a tracking ID. Continue watching the same task before sending it again."), {
|
|
144
175
|
details: { sessionId, clientId, recovery: { tool: "qingzhou_watch_task", arguments: { sessionId, clientId } } },
|
|
145
176
|
});
|
|
146
177
|
}
|
|
@@ -156,44 +187,131 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
|
|
|
156
187
|
/** Validate input against the advertised tool schema and apply its defaults. */
|
|
157
188
|
export function validateMcpToolInput(name, input = {}) {
|
|
158
189
|
const definition = definitions.get(name);
|
|
159
|
-
if (!definition) throw Object.assign(new Error(`Unknown tool: ${name}. Read the MCP tool list for available names.`), { code: "
|
|
190
|
+
if (!definition) throw Object.assign(new Error(`Unknown tool: ${name}. Read the MCP tool list for available names.`), { code: "INVALID_ARGUMENTS" });
|
|
160
191
|
try {
|
|
161
192
|
return argumentsWithDefaults(definition, input);
|
|
162
193
|
} catch (error) {
|
|
163
|
-
error.code = "
|
|
194
|
+
error.code = "INVALID_ARGUMENTS";
|
|
164
195
|
throw error;
|
|
165
196
|
}
|
|
166
197
|
}
|
|
167
198
|
|
|
168
|
-
|
|
199
|
+
function imageEntries(value) {
|
|
200
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Array.isArray(value.images)) return [];
|
|
201
|
+
return value.images.filter(image => image && typeof image.data === "string" && typeof image.mimeType === "string");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function withoutImages(value) {
|
|
205
|
+
if (!imageEntries(value).length) return value;
|
|
206
|
+
const { images: _images, ...rest } = value;
|
|
207
|
+
return rest;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function imageBlock(image, meta) {
|
|
211
|
+
return {
|
|
212
|
+
type: "image",
|
|
213
|
+
data: image.data,
|
|
214
|
+
mimeType: image.mimeType,
|
|
215
|
+
...(meta && Object.keys(meta).length ? { _meta: meta } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function peelCallImages(value, callId, sink) {
|
|
220
|
+
const images = imageEntries(value);
|
|
221
|
+
if (!images.length) return value;
|
|
222
|
+
const placeholders = images.map((image, offset) => {
|
|
223
|
+
const meta = { ...(callId ? { id: callId } : {}), index: offset };
|
|
224
|
+
sink.push(imageBlock(image, meta));
|
|
225
|
+
return meta;
|
|
226
|
+
});
|
|
227
|
+
return { ...withoutImages(value), images: placeholders };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function resultToContent(result, { toolResult = false } = {}) {
|
|
169
231
|
if (result == null) return [];
|
|
170
|
-
if (Array.isArray(result)) return result.flatMap(resultToContent);
|
|
171
|
-
return [{ type: "text", text:
|
|
232
|
+
if (Array.isArray(result)) return result.flatMap(item => resultToContent(item));
|
|
233
|
+
if (typeof result !== "object") return [{ type: "text", text: String(result) }];
|
|
234
|
+
const nested = [];
|
|
235
|
+
let body = result;
|
|
236
|
+
if (toolResult && Array.isArray(result.results)) {
|
|
237
|
+
body = {
|
|
238
|
+
...result,
|
|
239
|
+
results: result.results.map(item => {
|
|
240
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
|
|
241
|
+
const callId = String(item.id || "");
|
|
242
|
+
return peelCallImages(item, callId, nested);
|
|
243
|
+
}),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const top = toolResult ? imageEntries(body) : [];
|
|
247
|
+
if (!top.length && !nested.length) {
|
|
248
|
+
return [{ type: "text", text: JSON.stringify(body) }];
|
|
249
|
+
}
|
|
250
|
+
return [
|
|
251
|
+
{ type: "text", text: JSON.stringify(top.length ? withoutImages(body) : body) },
|
|
252
|
+
...top.map(image => imageBlock(image)),
|
|
253
|
+
...nested,
|
|
254
|
+
];
|
|
172
255
|
}
|
|
173
256
|
|
|
174
|
-
|
|
257
|
+
const DRIVE_TOOL_NAMES = ["qingzhou_guide", "qingzhou_create_drive_session", "qingzhou_list_drive_sessions", "qingzhou_get_drive_session", "qingzhou_stop_session", "qingzhou_execute_tool", "qingzhou_list_tools", "qingzhou_get_tool", "qingzhou_bridge_status"];
|
|
258
|
+
const GUIDE_TOOL = {
|
|
259
|
+
name: "qingzhou_guide",
|
|
260
|
+
description: "Return the qz CLI direct-drive guide as an optional reference.",
|
|
261
|
+
inputSchema: {
|
|
262
|
+
type: "object",
|
|
263
|
+
additionalProperties: false,
|
|
264
|
+
properties: {},
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
const DRIVE_INSTRUCTIONS = "先用 qingzhou_create_drive_session 或 qz session create --title TITLE 创建带可读标题的会话,后续每次执行工具或脚本都传入该 sessionId(CLI 必传 --session,缺失退出码为 2,不会自动建会话);list_tools/get_tool 查询基础目录无需会话。页面工具必传 tabRef(open_tab 设置的可选 tabAlias,或 open_tab / list_tabs 返回的 tabRef)。通过 qingzhou_execute_tool(单工具、calls 批次或 run_script 外层编排)执行操作,页面内 DOM 求值使用 evaluate。Create a session with a readable title using qingzhou_create_drive_session or qz session create --title TITLE, then pass that sessionId on every tool or script execution (CLI calls require --session; omitting it exits 2 and does not create a session). list_tools/get_tool can inspect the base catalog without a session. Page tools require tabRef (an optional tabAlias from open_tab, or the tabRef returned by open_tab or list_tabs). Orchestrate with qingzhou_execute_tool (single tool, calls batch, or outer run_script) and use evaluate for in-page DOM execution.";
|
|
268
|
+
|
|
269
|
+
export function mcpCatalogProfile({ clientName = "", profile = "" } = {}) {
|
|
270
|
+
const explicit = String(profile || "").trim().toLowerCase();
|
|
271
|
+
if (explicit === "full" || explicit === "drive") return explicit;
|
|
272
|
+
return clientName === "qz-cli" ? "full" : "drive";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function driveToolCatalog() {
|
|
276
|
+
const byName = new Map(TOOL_CATALOG.map(tool => [tool.name, tool]));
|
|
277
|
+
return DRIVE_TOOL_NAMES.map(name => name === "qingzhou_guide" ? GUIDE_TOOL : byName.get(name));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function createMcpServer(state, { clientName = "", profile = "" } = {}) {
|
|
281
|
+
const catalogProfile = mcpCatalogProfile({ clientName, profile });
|
|
175
282
|
const server = new Server({ name: "qz-browser-agent-mcp-bridge", version: PACKAGE_VERSION }, {
|
|
176
|
-
capabilities: { tools: {} },
|
|
283
|
+
capabilities: { tools: {} },
|
|
284
|
+
instructions: catalogProfile === "drive" ? DRIVE_INSTRUCTIONS : "Control qz tasks and browser tools through the local bridge.",
|
|
177
285
|
});
|
|
178
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
286
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
287
|
+
tools: catalogProfile === "drive" ? driveToolCatalog() : TOOL_CATALOG,
|
|
288
|
+
}));
|
|
179
289
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
180
290
|
try {
|
|
181
291
|
let progress = 0;
|
|
182
292
|
const progressToken = request.params._meta?.progressToken;
|
|
183
|
-
const
|
|
293
|
+
const input = { ...(request.params.arguments || {}) };
|
|
294
|
+
if (request.params.name === "qingzhou_guide") {
|
|
295
|
+
return { content: [{ type: "text", text: driveGuide() }] };
|
|
296
|
+
}
|
|
297
|
+
if (catalogProfile === "drive" && request.params.name === "qingzhou_list_sessions") {
|
|
298
|
+
if (!String(input.scope || "").trim()) input.scope = "session";
|
|
299
|
+
}
|
|
300
|
+
if (request.params.name === "qingzhou_execute_tool" && !String(input.sessionId || "").trim()) {
|
|
301
|
+
throw new BridgeError("DRIVE_SESSION_REQUIRED", "Pass sessionId from qz session create (MCP: qingzhou_create_drive_session). CLI calls require --session. This call does not create a session.");
|
|
302
|
+
}
|
|
303
|
+
const result = await executeMcpTool(state, request.params.name, input, { signal: extra.signal, clientName,
|
|
184
304
|
onTaskUpdate: progressToken === undefined ? undefined : event => extra.sendNotification({
|
|
185
305
|
method: "notifications/progress", params: { progressToken, progress: ++progress,
|
|
186
306
|
message: ["text", "replace"].includes(event.type) ? event.text : JSON.stringify(event),
|
|
187
307
|
_meta: { "qz/task-event": event } },
|
|
188
308
|
}).catch(() => {}),
|
|
189
309
|
});
|
|
190
|
-
return { content: resultToContent(result) };
|
|
310
|
+
return { content: resultToContent(result, { toolResult: request.params.name === "qingzhou_execute_tool" }) };
|
|
191
311
|
} catch (error) {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
...(error.details && Object.keys(error.details).length ? { details: error.details } : {}),
|
|
196
|
-
} : message;
|
|
312
|
+
const value = { code: String(error.code || "TOOL_EXECUTION_FAILED").toUpperCase(), message: error.message || String(error),
|
|
313
|
+
...(error.hint ? { hint: error.hint } : {}),
|
|
314
|
+
...(error.details && Object.keys(error.details).length ? { details: error.details } : {}) };
|
|
197
315
|
return { isError: true, content: resultToContent(value) };
|
|
198
316
|
}
|
|
199
317
|
});
|