hanzi-browse 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -0
- package/dist/agent/loop.d.ts +63 -0
- package/dist/agent/loop.js +186 -0
- package/dist/agent/system-prompt.d.ts +7 -0
- package/dist/agent/system-prompt.js +41 -0
- package/dist/agent/tools.d.ts +9 -0
- package/dist/agent/tools.js +154 -0
- package/dist/cli/detect-credentials.d.ts +31 -0
- package/dist/cli/detect-credentials.js +44 -0
- package/dist/cli/import-credentials-handler.d.ts +14 -0
- package/dist/cli/import-credentials-handler.js +22 -0
- package/dist/cli/session-files.d.ts +28 -0
- package/dist/cli/session-files.js +118 -0
- package/dist/cli/setup.d.ts +10 -0
- package/dist/cli/setup.js +915 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +506 -0
- package/dist/dashboard/assets/index-CEFyesbT.js +46 -0
- package/dist/dashboard/assets/index-Dnht2kLU.css +1 -0
- package/dist/dashboard/index.html +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1116 -0
- package/dist/ipc/index.d.ts +8 -0
- package/dist/ipc/index.js +8 -0
- package/dist/ipc/native-host.d.ts +96 -0
- package/dist/ipc/native-host.js +223 -0
- package/dist/ipc/websocket-client.d.ts +73 -0
- package/dist/ipc/websocket-client.js +199 -0
- package/dist/license/manager.d.ts +20 -0
- package/dist/license/manager.js +15 -0
- package/dist/llm/client.d.ts +72 -0
- package/dist/llm/client.js +227 -0
- package/dist/llm/credentials.d.ts +61 -0
- package/dist/llm/credentials.js +200 -0
- package/dist/llm/vertex.d.ts +22 -0
- package/dist/llm/vertex.js +335 -0
- package/dist/managed/api-http.test.d.ts +7 -0
- package/dist/managed/api-http.test.js +623 -0
- package/dist/managed/api.d.ts +51 -0
- package/dist/managed/api.js +1448 -0
- package/dist/managed/api.test.d.ts +10 -0
- package/dist/managed/api.test.js +146 -0
- package/dist/managed/auth.d.ts +38 -0
- package/dist/managed/auth.js +192 -0
- package/dist/managed/billing.d.ts +70 -0
- package/dist/managed/billing.js +227 -0
- package/dist/managed/deploy.d.ts +17 -0
- package/dist/managed/deploy.js +385 -0
- package/dist/managed/e2e.test.d.ts +15 -0
- package/dist/managed/e2e.test.js +151 -0
- package/dist/managed/hardening.test.d.ts +14 -0
- package/dist/managed/hardening.test.js +346 -0
- package/dist/managed/integration.test.d.ts +8 -0
- package/dist/managed/integration.test.js +274 -0
- package/dist/managed/log.d.ts +18 -0
- package/dist/managed/log.js +31 -0
- package/dist/managed/server.d.ts +12 -0
- package/dist/managed/server.js +69 -0
- package/dist/managed/store-pg.d.ts +191 -0
- package/dist/managed/store-pg.js +479 -0
- package/dist/managed/store.d.ts +188 -0
- package/dist/managed/store.js +379 -0
- package/dist/relay/auto-start.d.ts +19 -0
- package/dist/relay/auto-start.js +71 -0
- package/dist/relay/server.d.ts +17 -0
- package/dist/relay/server.js +403 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +4 -0
- package/dist/types/session.d.ts +134 -0
- package/dist/types/session.js +16 -0
- package/package.json +61 -0
- package/skills/README.md +48 -0
- package/skills/a11y-auditor/SKILL.md +42 -0
- package/skills/e2e-tester/SKILL.md +154 -0
- package/skills/hanzi-browse/SKILL.md +182 -0
- package/skills/linkedin-prospector/SKILL.md +149 -0
- package/skills/social-poster/SKILL.md +146 -0
- package/skills/x-marketer/SKILL.md +479 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// If invoked as `npx hanzi-browse setup`, delegate to the CLI
|
|
3
|
+
if (process.argv[2] === 'setup') {
|
|
4
|
+
const { fileURLToPath } = await import('url');
|
|
5
|
+
const { dirname, join } = await import('path');
|
|
6
|
+
const { execFileSync } = await import('child_process');
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const cliPath = join(__dirname, 'cli.js');
|
|
9
|
+
try {
|
|
10
|
+
execFileSync(process.execPath, [cliPath, ...process.argv.slice(2)], { stdio: 'inherit' });
|
|
11
|
+
}
|
|
12
|
+
catch { /* exit code propagated */ }
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Hanzi Browse MCP Server
|
|
17
|
+
*
|
|
18
|
+
* MCP transport + session wrapper for the extension-side browser agent.
|
|
19
|
+
* The Chrome extension owns browser execution; this server forwards tasks,
|
|
20
|
+
* tracks session metadata, and waits for completion events.
|
|
21
|
+
*/
|
|
22
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
23
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
24
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
25
|
+
import { WebSocketClient } from "./ipc/websocket-client.js";
|
|
26
|
+
import { randomUUID } from "crypto";
|
|
27
|
+
import { readFileSync } from "fs";
|
|
28
|
+
import { join, dirname } from "path";
|
|
29
|
+
import { fileURLToPath } from "url";
|
|
30
|
+
import { exec } from "child_process";
|
|
31
|
+
import { describeCredentials, resolveCredentials } from "./llm/credentials.js";
|
|
32
|
+
import { callLLM } from "./llm/client.js";
|
|
33
|
+
import { checkAndIncrementUsage, getLicenseStatus } from "./license/manager.js";
|
|
34
|
+
// --- Managed proxy mode ---
|
|
35
|
+
// When HANZI_API_KEY is set, tasks are proxied to the managed API instead of
|
|
36
|
+
// running locally. This lets users without their own LLM key use Hanzi managed.
|
|
37
|
+
const MANAGED_API_KEY = process.env.HANZI_API_KEY;
|
|
38
|
+
const MANAGED_API_URL = process.env.HANZI_API_URL || "https://api.hanzilla.co";
|
|
39
|
+
const IS_MANAGED_MODE = !!MANAGED_API_KEY;
|
|
40
|
+
async function managedApiCall(method, path, body) {
|
|
41
|
+
const res = await fetch(`${MANAGED_API_URL}${path}`, {
|
|
42
|
+
method,
|
|
43
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${MANAGED_API_KEY}` },
|
|
44
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
45
|
+
});
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
48
|
+
async function runManagedTask(task, url, context) {
|
|
49
|
+
// Find a connected browser session
|
|
50
|
+
const sessionsRes = await managedApiCall("GET", "/v1/browser-sessions");
|
|
51
|
+
const connected = sessionsRes?.sessions?.find((s) => s.status === "connected");
|
|
52
|
+
if (!connected) {
|
|
53
|
+
return { status: "error", answer: "No browser connected. Open Chrome with the Hanzi extension and pair it first.", steps: 0 };
|
|
54
|
+
}
|
|
55
|
+
// Create task
|
|
56
|
+
const created = await managedApiCall("POST", "/v1/tasks", {
|
|
57
|
+
task, url, context, browser_session_id: connected.id,
|
|
58
|
+
});
|
|
59
|
+
if (created.error)
|
|
60
|
+
return { status: "error", answer: created.error, steps: 0 };
|
|
61
|
+
// Poll until done (max 5 min)
|
|
62
|
+
const taskId = created.id;
|
|
63
|
+
const deadline = Date.now() + TASK_TIMEOUT_MS;
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
66
|
+
const status = await managedApiCall("GET", `/v1/tasks/${taskId}`);
|
|
67
|
+
if (status.status !== "running") {
|
|
68
|
+
return {
|
|
69
|
+
status: status.status,
|
|
70
|
+
answer: status.answer || "No answer.",
|
|
71
|
+
steps: status.steps || 0,
|
|
72
|
+
error: status.error,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { status: "timeout", answer: "Task still running. Check back later.", steps: 0 };
|
|
77
|
+
}
|
|
78
|
+
const sessions = new Map();
|
|
79
|
+
const pendingScreenshots = new Map();
|
|
80
|
+
// Max time a task can run before we return (configurable, default 5 minutes)
|
|
81
|
+
const TASK_TIMEOUT_MS = parseInt(process.env.HANZI_BROWSE_TIMEOUT_MS || String(5 * 60 * 1000), 10);
|
|
82
|
+
const MAX_CONCURRENT = parseInt(process.env.HANZI_BROWSE_MAX_SESSIONS || "5", 10);
|
|
83
|
+
const SESSION_TTL_MS = parseInt(process.env.HANZI_BROWSE_SESSION_TTL_MS || String(60 * 60 * 1000), 10);
|
|
84
|
+
// WebSocket relay connection
|
|
85
|
+
let connection;
|
|
86
|
+
const pendingWaiters = [];
|
|
87
|
+
/**
|
|
88
|
+
* Wait for a specific message from the extension via WebSocket relay.
|
|
89
|
+
* Returns null on timeout.
|
|
90
|
+
*/
|
|
91
|
+
function waitForRelayMessage(filter, timeoutMs = 60000) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const timeout = setTimeout(() => {
|
|
94
|
+
const idx = pendingWaiters.findIndex((w) => w.resolve === resolve);
|
|
95
|
+
if (idx !== -1)
|
|
96
|
+
pendingWaiters.splice(idx, 1);
|
|
97
|
+
resolve(null);
|
|
98
|
+
}, timeoutMs);
|
|
99
|
+
pendingWaiters.push({ filter, resolve, timeout });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Route incoming relay messages to pending waiters.
|
|
104
|
+
*/
|
|
105
|
+
async function handleMessage(message) {
|
|
106
|
+
if (message?.type === "mcp_get_info") {
|
|
107
|
+
void handleGetInfoRequest(message);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (message?.type === "mcp_escalate") {
|
|
111
|
+
void handleEscalationRequest(message);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
updateSessionFromMessage(message);
|
|
115
|
+
// Check pending waiters first
|
|
116
|
+
for (let i = 0; i < pendingWaiters.length; i++) {
|
|
117
|
+
const waiter = pendingWaiters[i];
|
|
118
|
+
if (waiter.filter(message)) {
|
|
119
|
+
clearTimeout(waiter.timeout);
|
|
120
|
+
pendingWaiters.splice(i, 1);
|
|
121
|
+
waiter.resolve(message);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Handle screenshots for pending requests
|
|
126
|
+
const { type, sessionId, ...data } = message;
|
|
127
|
+
if (type === "screenshot" && data.data && sessionId) {
|
|
128
|
+
const pending = pendingScreenshots.get(sessionId);
|
|
129
|
+
if (pending) {
|
|
130
|
+
clearTimeout(pending.timeout);
|
|
131
|
+
pending.resolve(data.data);
|
|
132
|
+
pendingScreenshots.delete(sessionId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function send(message) {
|
|
137
|
+
await connection.send(message);
|
|
138
|
+
}
|
|
139
|
+
async function callTextModel(systemText, userText, maxTokens = 700) {
|
|
140
|
+
const response = await callLLM({
|
|
141
|
+
messages: [{ role: "user", content: userText }],
|
|
142
|
+
system: [{ type: "text", text: systemText }],
|
|
143
|
+
tools: [],
|
|
144
|
+
maxTokens,
|
|
145
|
+
});
|
|
146
|
+
const text = response.content
|
|
147
|
+
.filter((block) => block.type === "text")
|
|
148
|
+
.map((block) => block.text)
|
|
149
|
+
.join("")
|
|
150
|
+
.trim();
|
|
151
|
+
if (!text) {
|
|
152
|
+
throw new Error("LLM returned no text content");
|
|
153
|
+
}
|
|
154
|
+
return text;
|
|
155
|
+
}
|
|
156
|
+
async function handleGetInfoRequest(message) {
|
|
157
|
+
const { sessionId, query, requestId } = message;
|
|
158
|
+
if (!requestId)
|
|
159
|
+
return;
|
|
160
|
+
const session = typeof sessionId === "string" ? sessions.get(sessionId) : undefined;
|
|
161
|
+
const context = session?.context?.trim();
|
|
162
|
+
let responseText;
|
|
163
|
+
if (!context) {
|
|
164
|
+
responseText = `Information not found: no task context was provided for this session.`;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
try {
|
|
168
|
+
responseText = await callTextModel("Answer the user's query using only the provided task context. If the context does not contain the answer, reply exactly with 'Information not found.' Do not invent facts.", `Task context:\n${context}\n\nQuery:\n${query}`, 500);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
responseText = `Information lookup failed: ${error.message}. Raw task context:\n${context}`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
await send({
|
|
175
|
+
type: "mcp_get_info_response",
|
|
176
|
+
sessionId,
|
|
177
|
+
requestId,
|
|
178
|
+
response: responseText,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async function handleEscalationRequest(message) {
|
|
182
|
+
const { sessionId, requestId, problem, whatITried, whatINeed } = message;
|
|
183
|
+
if (!requestId)
|
|
184
|
+
return;
|
|
185
|
+
const session = typeof sessionId === "string" ? sessions.get(sessionId) : undefined;
|
|
186
|
+
const taskSummary = session
|
|
187
|
+
? `Task: ${session.task}\nContext: ${session.context || "(none)"}\nRecent steps:\n${session.steps.slice(-8).join("\n") || "(none)"}`
|
|
188
|
+
: "Task/session state unavailable.";
|
|
189
|
+
let responseText;
|
|
190
|
+
try {
|
|
191
|
+
responseText = await callTextModel("You are a planning assistant helping a browser automation agent recover from a blocker. Give short, concrete next-step guidance. Prefer actions the browser agent can try immediately. If user input is required, say exactly what is missing.", `Session state:\n${taskSummary}\n\nProblem:\n${problem}\n\nWhat I tried:\n${whatITried || "(not provided)"}\n\nWhat I need:\n${whatINeed || "(not provided)"}`, 600);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
responseText = `Escalation handling failed: ${error.message}. Try a smaller step, re-read the page, or request the missing information explicitly.`;
|
|
195
|
+
}
|
|
196
|
+
await send({
|
|
197
|
+
type: "mcp_escalate_response",
|
|
198
|
+
sessionId,
|
|
199
|
+
requestId,
|
|
200
|
+
response: responseText,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
function extractAnswer(result) {
|
|
204
|
+
if (result == null)
|
|
205
|
+
return undefined;
|
|
206
|
+
if (typeof result === "string")
|
|
207
|
+
return result;
|
|
208
|
+
if (typeof result === "object") {
|
|
209
|
+
const maybeMessage = result.message;
|
|
210
|
+
if (typeof maybeMessage === "string")
|
|
211
|
+
return maybeMessage;
|
|
212
|
+
return JSON.stringify(result);
|
|
213
|
+
}
|
|
214
|
+
return String(result);
|
|
215
|
+
}
|
|
216
|
+
function updateSessionFromMessage(message) {
|
|
217
|
+
const sessionId = message?.sessionId;
|
|
218
|
+
if (!sessionId)
|
|
219
|
+
return;
|
|
220
|
+
const session = sessions.get(sessionId);
|
|
221
|
+
if (!session)
|
|
222
|
+
return;
|
|
223
|
+
session.updatedAt = Date.now();
|
|
224
|
+
switch (message.type) {
|
|
225
|
+
case "task_update":
|
|
226
|
+
session.status = message.status === "running" ? "running" : session.status;
|
|
227
|
+
if (typeof message.step === "string" && message.step.trim()) {
|
|
228
|
+
const lastStep = session.steps[session.steps.length - 1];
|
|
229
|
+
if (lastStep !== message.step) {
|
|
230
|
+
session.steps.push(message.step);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
case "task_complete":
|
|
235
|
+
session.status = "complete";
|
|
236
|
+
session.answer = extractAnswer(message.result);
|
|
237
|
+
session.error = undefined;
|
|
238
|
+
break;
|
|
239
|
+
case "task_error":
|
|
240
|
+
session.status = "error";
|
|
241
|
+
session.answer = undefined;
|
|
242
|
+
session.error = typeof message.error === "string" ? message.error : "Task failed";
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function formatResult(session) {
|
|
247
|
+
const result = {
|
|
248
|
+
session_id: session.id,
|
|
249
|
+
status: session.status,
|
|
250
|
+
task: session.task,
|
|
251
|
+
};
|
|
252
|
+
if (session.answer)
|
|
253
|
+
result.answer = session.answer;
|
|
254
|
+
if (session.error)
|
|
255
|
+
result.error = session.error;
|
|
256
|
+
if (session.steps.length > 0) {
|
|
257
|
+
result.total_steps = session.steps.length;
|
|
258
|
+
result.recent_steps = session.steps.slice(-5);
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
function waitForSessionTerminal(sessionId, timeoutMs = TASK_TIMEOUT_MS) {
|
|
263
|
+
return waitForRelayMessage((msg) => msg.sessionId === sessionId &&
|
|
264
|
+
(msg.type === "task_complete" || msg.type === "task_error"), timeoutMs);
|
|
265
|
+
}
|
|
266
|
+
// --- Helpers ---
|
|
267
|
+
const EXTENSION_URL = "https://chromewebstore.google.com/detail/hanzi-browse/iklpkemlmbhemkiojndpbhoakgikpmcd";
|
|
268
|
+
function openInBrowser(url) {
|
|
269
|
+
const cmd = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
270
|
+
exec(`${cmd} "${url}"`);
|
|
271
|
+
}
|
|
272
|
+
// --- Extension connectivity check ---
|
|
273
|
+
function checkExtensionOnce() {
|
|
274
|
+
return new Promise((resolve) => {
|
|
275
|
+
const requestId = `status-${Date.now()}-${randomUUID().slice(0, 4)}`;
|
|
276
|
+
const timeout = setTimeout(() => {
|
|
277
|
+
connection.offMessage(handler);
|
|
278
|
+
resolve(false);
|
|
279
|
+
}, 2000);
|
|
280
|
+
const handler = (msg) => {
|
|
281
|
+
if (msg.type === "status_response" && msg.requestId === requestId) {
|
|
282
|
+
clearTimeout(timeout);
|
|
283
|
+
connection.offMessage(handler);
|
|
284
|
+
resolve(msg.extensionConnected === true);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
connection.onMessage(handler);
|
|
288
|
+
connection.send({ type: "status_query", requestId }).catch(() => resolve(false));
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
async function isExtensionConnected() {
|
|
292
|
+
// Chrome suspends MV3 service workers after ~30s of inactivity, dropping the
|
|
293
|
+
// WebSocket. The relay pings the extension every 20s to prevent this, but if
|
|
294
|
+
// the connection was already lost, wait for the keepalive alarm to reconnect.
|
|
295
|
+
const MAX_RETRIES = 5;
|
|
296
|
+
const RETRY_DELAY_MS = 3000;
|
|
297
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
298
|
+
if (await checkExtensionOnce())
|
|
299
|
+
return true;
|
|
300
|
+
if (i === 0) {
|
|
301
|
+
console.error("[MCP] Extension not connected, waiting for service worker to wake up...");
|
|
302
|
+
}
|
|
303
|
+
if (i < MAX_RETRIES - 1) {
|
|
304
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
setInterval(() => {
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
for (const [sessionId, session] of sessions) {
|
|
312
|
+
if (session.status === "running")
|
|
313
|
+
continue;
|
|
314
|
+
if (now - session.updatedAt > SESSION_TTL_MS) {
|
|
315
|
+
sessions.delete(sessionId);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}, 5 * 60 * 1000);
|
|
319
|
+
// --- Tool definitions ---
|
|
320
|
+
const TOOLS = [
|
|
321
|
+
{
|
|
322
|
+
name: "browser_start",
|
|
323
|
+
description: `Start a browser automation task. Controls the user's real Chrome browser with their existing logins, cookies, and sessions.
|
|
324
|
+
|
|
325
|
+
An autonomous agent navigates, clicks, types, and fills forms. Blocks until complete or timeout (5 min). You can run multiple browser_start calls in parallel — each gets its own browser window.
|
|
326
|
+
|
|
327
|
+
WHEN TO USE — only when you need a real browser and no other tool can do it:
|
|
328
|
+
- Clicking, typing, filling forms, navigating menus, selecting dropdowns
|
|
329
|
+
- Testing workflows: "sign up for an account and verify the welcome email arrives"
|
|
330
|
+
- Posting or publishing: write a LinkedIn post, send a Slack message, submit a forum reply, post a tweet
|
|
331
|
+
- Authenticated pages: read a Jira ticket, check GitHub PR status, pull data from an analytics dashboard, check order status — the user is already logged in
|
|
332
|
+
- Dynamic / JS-rendered pages: SPAs, dashboards, infinite scroll — content that plain fetch can't reach
|
|
333
|
+
- Multi-step tasks: "find flights from A to B, compare prices, and pick the cheapest"
|
|
334
|
+
|
|
335
|
+
WHEN NOT TO USE — always prefer faster tools first:
|
|
336
|
+
- If you have an API, MCP tool, or CLI command that can accomplish the task, use that instead. Browser automation is slower and should be a last resort.
|
|
337
|
+
- Factual or general knowledge questions — just answer directly
|
|
338
|
+
- Web search — use built-in web search or a search MCP
|
|
339
|
+
- Reading public/static pages — use a fetch, reader, or web scraping tool
|
|
340
|
+
- GitHub, Jira, Slack, etc. — use their dedicated API or MCP tool if available
|
|
341
|
+
- API requests — use curl or an HTTP tool
|
|
342
|
+
- Code, files, or anything that doesn't need a browser
|
|
343
|
+
|
|
344
|
+
Return statuses:
|
|
345
|
+
- "complete" — task succeeded, result in "answer"
|
|
346
|
+
- "error" — task failed. Call browser_screenshot to see the page, then browser_message to retry or browser_stop to clean up.
|
|
347
|
+
- "timeout" — the 5-minute window elapsed but the task is still running in the browser. This is normal for long tasks. Call browser_screenshot to check progress, then browser_message to continue or browser_stop to end.`,
|
|
348
|
+
inputSchema: {
|
|
349
|
+
type: "object",
|
|
350
|
+
properties: {
|
|
351
|
+
task: {
|
|
352
|
+
type: "string",
|
|
353
|
+
description: "What you want done in the browser. Be specific: include the website, the goal, and any details that matter.",
|
|
354
|
+
},
|
|
355
|
+
url: {
|
|
356
|
+
type: "string",
|
|
357
|
+
description: "Starting URL to navigate to before the task begins.",
|
|
358
|
+
},
|
|
359
|
+
context: {
|
|
360
|
+
type: "string",
|
|
361
|
+
description: "All the information the agent might need: form field values, text to paste, tone/style preferences, credentials, choices to make.",
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
required: ["task"],
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: "browser_message",
|
|
369
|
+
description: `Send a follow-up message to a running or finished browser session. Blocks until the agent acts on it.
|
|
370
|
+
|
|
371
|
+
Use cases:
|
|
372
|
+
- Correct or refine: "actually change the quantity to 3", "use the second address instead"
|
|
373
|
+
- Continue after completion: "now click the Download button", "go to the next page and do the same thing"
|
|
374
|
+
- Retry after error: "try again", "click the other link instead"
|
|
375
|
+
|
|
376
|
+
The browser window is still open from the original browser_start call, so the agent picks up exactly where it left off.`,
|
|
377
|
+
inputSchema: {
|
|
378
|
+
type: "object",
|
|
379
|
+
properties: {
|
|
380
|
+
session_id: { type: "string", description: "Session ID from browser_start." },
|
|
381
|
+
message: { type: "string", description: "Follow-up instructions or answer to the agent's question." },
|
|
382
|
+
},
|
|
383
|
+
required: ["session_id", "message"],
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: "browser_status",
|
|
388
|
+
description: `Check the current status of browser sessions.
|
|
389
|
+
|
|
390
|
+
Returns session ID, status, task description, and the last 5 steps.`,
|
|
391
|
+
inputSchema: {
|
|
392
|
+
type: "object",
|
|
393
|
+
properties: {
|
|
394
|
+
session_id: { type: "string", description: "Check a specific session. If omitted, returns all running sessions." },
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
name: "browser_stop",
|
|
400
|
+
description: `Stop a browser session. The agent stops but the browser window stays open so the user can review the result.
|
|
401
|
+
|
|
402
|
+
Without "remove", the session can still be resumed later with browser_message. With "remove: true", the browser window closes and the session is permanently deleted.`,
|
|
403
|
+
inputSchema: {
|
|
404
|
+
type: "object",
|
|
405
|
+
properties: {
|
|
406
|
+
session_id: { type: "string", description: "Session to stop." },
|
|
407
|
+
remove: { type: "boolean", description: "If true, also close the browser window and delete session history." },
|
|
408
|
+
},
|
|
409
|
+
required: ["session_id"],
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
name: "browser_screenshot",
|
|
414
|
+
description: `Capture a screenshot of the current browser page. Returns a PNG image.
|
|
415
|
+
|
|
416
|
+
Call this when browser_start returns "error" or times out — see what the agent was looking at.`,
|
|
417
|
+
inputSchema: {
|
|
418
|
+
type: "object",
|
|
419
|
+
properties: {
|
|
420
|
+
session_id: { type: "string", description: "Session to screenshot. If omitted, captures the currently active tab." },
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
];
|
|
425
|
+
// --- MCP Server ---
|
|
426
|
+
const server = new Server({ name: "browser-automation", version: "2.0.0" }, { capabilities: { tools: { listChanged: false }, prompts: { listChanged: false } } });
|
|
427
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
428
|
+
// --- Prompts ---
|
|
429
|
+
const PROMPTS = [
|
|
430
|
+
{
|
|
431
|
+
name: "linkedin-prospector",
|
|
432
|
+
description: "Find people on LinkedIn and send personalized connection requests. Uses your real signed-in browser — LinkedIn has no API for this. Supports networking, sales, partnerships, and hiring strategies. Each connection note is unique.",
|
|
433
|
+
arguments: [
|
|
434
|
+
{ name: "goal", description: "What you're trying to achieve: networking, sales, partnerships, hiring, or market-research", required: true },
|
|
435
|
+
{ name: "topic", description: "Topic, industry, or product area (e.g., 'browser automation', 'AI DevTools')", required: true },
|
|
436
|
+
{ name: "count", description: "How many people to find (default: 15)", required: false },
|
|
437
|
+
{ name: "context", description: "Extra context: your product, company, what you offer, who your ideal target is", required: false },
|
|
438
|
+
],
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
name: "e2e-tester",
|
|
442
|
+
description: "Test a web app in your real browser — click through flows and report what's broken with screenshots and code references. Gathers context from the codebase first, then uses the browser only for UI interaction and visual verification. Works on localhost, staging, and preview URLs.",
|
|
443
|
+
arguments: [
|
|
444
|
+
{ name: "url", description: "App URL to test (e.g., 'localhost:3000', 'staging.myapp.com')", required: true },
|
|
445
|
+
{ name: "what", description: "What to test: 'signup flow', 'checkout', 'everything', or 'what I just changed'", required: false },
|
|
446
|
+
{ name: "credentials", description: "Test login credentials if needed (e.g., 'test@test.com / password123')", required: false },
|
|
447
|
+
],
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: "social-poster",
|
|
451
|
+
description: "Post content across social platforms from your real signed-in browser. Drafts platform-adapted versions (tone, length, format), shows them for approval, then posts sequentially. Works with LinkedIn, Twitter/X, Reddit, Hacker News, and Product Hunt.",
|
|
452
|
+
arguments: [
|
|
453
|
+
{ name: "content", description: "What to post about: a topic, announcement, 'our latest release', or the exact text", required: true },
|
|
454
|
+
{ name: "platforms", description: "Where to post: 'linkedin', 'twitter', 'reddit', 'hackernews', 'producthunt', or 'all' (default: linkedin + twitter)", required: false },
|
|
455
|
+
{ name: "context", description: "Extra context: link to include, images, tone preference, target audience", required: false },
|
|
456
|
+
],
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
name: "x-marketer",
|
|
460
|
+
description: "Find conversations on X/Twitter where people discuss problems your product solves, research each author, draft voice-matched replies, and post from your real signed-in account. Supports three modes: conversations (find pain points), influencers (warm up large accounts), brand (monitor mentions). Loads your voice profile for natural-sounding replies.",
|
|
461
|
+
arguments: [
|
|
462
|
+
{ name: "product", description: "Product name, URL, and one-line description", required: true },
|
|
463
|
+
{ name: "keywords", description: "Search terms to find relevant conversations (comma-separated)", required: true },
|
|
464
|
+
{ name: "mode", description: "conversations (default), influencers, or brand", required: false },
|
|
465
|
+
{ name: "count", description: "How many engagements per session (default: 10, max: 15)", required: false },
|
|
466
|
+
{ name: "context", description: "Extra context: pain points, competitors to avoid, tone preference", required: false },
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
];
|
|
470
|
+
const PROMPT_TEMPLATES = {
|
|
471
|
+
"linkedin-prospector": (args) => {
|
|
472
|
+
const count = args.count || "15";
|
|
473
|
+
const goal = (args.goal || "networking").toLowerCase();
|
|
474
|
+
const topic = args.topic || "";
|
|
475
|
+
const context = args.context || "";
|
|
476
|
+
return {
|
|
477
|
+
description: "Find LinkedIn prospects and send personalized connections",
|
|
478
|
+
messages: [
|
|
479
|
+
{
|
|
480
|
+
role: "user",
|
|
481
|
+
content: {
|
|
482
|
+
type: "text",
|
|
483
|
+
text: `Find ${count} people on LinkedIn related to "${topic}" and send personalized connection requests.
|
|
484
|
+
|
|
485
|
+
My goal: **${goal}**
|
|
486
|
+
${context ? `\nContext about me/my product: ${context}` : ""}
|
|
487
|
+
|
|
488
|
+
## Tool selection rule
|
|
489
|
+
|
|
490
|
+
- Prefer existing tools first: code search, git diff, logs, APIs, local files, and other MCP integrations.
|
|
491
|
+
- Use Hanzi only for browser-required steps: LinkedIn prospecting is a logged-in UI workflow with no useful public API for this job.
|
|
492
|
+
- If LinkedIn shows a rate limit warning, CAPTCHA, or risk signal, stop immediately and tell me.
|
|
493
|
+
|
|
494
|
+
## Step 1: Choose the right search strategy
|
|
495
|
+
|
|
496
|
+
Based on my goal, pick the best approach (or combine them):
|
|
497
|
+
|
|
498
|
+
**Networking / community building** → Search LinkedIn POSTS. Find people actively talking about the topic. These are engaged, vocal people — great for community.
|
|
499
|
+
URL: https://www.linkedin.com/search/results/content/?keywords=${encodeURIComponent(topic)}
|
|
500
|
+
|
|
501
|
+
**Sales prospecting** → Search LinkedIn PEOPLE with role/industry filters. Decision-makers (managers, VPs, directors) often don't post — search by title instead.
|
|
502
|
+
URL: https://www.linkedin.com/search/results/people/?keywords=${encodeURIComponent(topic)}
|
|
503
|
+
Add filters: use LinkedIn's built-in filters for seniority level, industry, company size, location.
|
|
504
|
+
|
|
505
|
+
**Partnerships / collaboration** → Combine both: search posts to find builders in the space, then search people for specific roles at relevant companies.
|
|
506
|
+
|
|
507
|
+
**Hiring** → Search people by skills and current role. Filter by location and experience level.
|
|
508
|
+
|
|
509
|
+
**Market research** → Search posts and read comments. Find what people are saying, who's engaging, what problems they mention.
|
|
510
|
+
|
|
511
|
+
Tell me which strategy you're going with before starting. If my goal suggests a clear strategy, just confirm it and proceed.
|
|
512
|
+
|
|
513
|
+
## Step 2: Collect prospects
|
|
514
|
+
|
|
515
|
+
For each person, gather personalization material. What you look for depends on how you found them:
|
|
516
|
+
|
|
517
|
+
- **Found via post search**: What they posted about, their take, any specific insight they shared
|
|
518
|
+
- **Found via people search**: Visit their profile. Look for: recent job change, About section, featured content, recent activity, mutual connections, company news
|
|
519
|
+
- **Found via both**: Combine signals — strongest personalization
|
|
520
|
+
|
|
521
|
+
Collect: name, headline, and at least one personalization hook per person.
|
|
522
|
+
|
|
523
|
+
## Step 3: Dedup with outreach log
|
|
524
|
+
|
|
525
|
+
Before searching, check prior outreach:
|
|
526
|
+
\`wc -l ~/.hanzi-browse/contacted.txt 2>/dev/null || echo "0 (new log)"\`
|
|
527
|
+
|
|
528
|
+
Before sending to each person:
|
|
529
|
+
\`grep -qiF "Name Here" ~/.hanzi-browse/contacted.txt 2>/dev/null\`
|
|
530
|
+
Skip if found (exit 0).
|
|
531
|
+
|
|
532
|
+
## Step 4: Show me the list before sending
|
|
533
|
+
|
|
534
|
+
Present a table:
|
|
535
|
+
| # | Name | Role / Company | Personalization hook | Why they match my goal | Status |
|
|
536
|
+
|
|
537
|
+
The "Personalization hook" column is key — it's the specific thing you'll reference in the note. If you don't have a strong hook for someone, flag it.
|
|
538
|
+
|
|
539
|
+
Ask me which ones to send to. I might want to adjust the list or the approach.
|
|
540
|
+
|
|
541
|
+
## Step 5: Send personalized connections
|
|
542
|
+
|
|
543
|
+
Send one at a time using separate browser_start calls — NOT in parallel.
|
|
544
|
+
|
|
545
|
+
Each connection note (max 300 chars) must:
|
|
546
|
+
1. **Lead with THEIR thing** — reference their post, project, role, company move, or profile detail
|
|
547
|
+
2. **Connect it to why you're reaching out** — make the relevance obvious
|
|
548
|
+
3. **Sound like a human** — conversational, not polished marketing copy
|
|
549
|
+
|
|
550
|
+
Personalization varies by how you found them:
|
|
551
|
+
|
|
552
|
+
**Post-based**: "Your post about [specific thing] resonated — I'm working on [related thing]. Would love to connect."
|
|
553
|
+
**Profile-based**: "Saw you're leading [team/initiative] at [company] — I'm building [relevant thing] and think there's overlap. Happy to share notes."
|
|
554
|
+
**Job-change-based**: "Congrats on the move to [company]! I work on [relevant thing] that might be useful as you're getting set up."
|
|
555
|
+
**Mutual-connection-based**: "We both know [person] — I noticed you're working on [thing] and thought we should connect."
|
|
556
|
+
|
|
557
|
+
After each send, log immediately:
|
|
558
|
+
\`mkdir -p ~/.hanzi-browse && echo "Name Here" >> ~/.hanzi-browse/contacted.txt\`
|
|
559
|
+
|
|
560
|
+
Report progress: "Sent 3/12 — continuing..."
|
|
561
|
+
|
|
562
|
+
## Safety rules
|
|
563
|
+
|
|
564
|
+
- Max 20 connection requests per session
|
|
565
|
+
- If LinkedIn shows a rate limit warning or CAPTCHA, stop immediately and tell me
|
|
566
|
+
- Every note must be unique — never copy-paste between people
|
|
567
|
+
- No links, no sales pitches, no product plugs in the connection note
|
|
568
|
+
- Don't send to people where you couldn't find a good personalization hook — skip and note why
|
|
569
|
+
|
|
570
|
+
## When done
|
|
571
|
+
|
|
572
|
+
Summarize:
|
|
573
|
+
- Strategy used and why
|
|
574
|
+
- Total found / sent / skipped (already contacted) / skipped (no good hook) / failed
|
|
575
|
+
- Running total from the log
|
|
576
|
+
- Any patterns noticed (common roles, topics, companies that kept appearing)`,
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
],
|
|
580
|
+
};
|
|
581
|
+
},
|
|
582
|
+
"e2e-tester": (args) => {
|
|
583
|
+
const url = args.url || "localhost:3000";
|
|
584
|
+
const what = args.what || "";
|
|
585
|
+
const credentials = args.credentials || "";
|
|
586
|
+
return {
|
|
587
|
+
description: "Test a web app in a real browser and report findings",
|
|
588
|
+
messages: [
|
|
589
|
+
{
|
|
590
|
+
role: "user",
|
|
591
|
+
content: {
|
|
592
|
+
type: "text",
|
|
593
|
+
text: `Test my web app at ${url} in a real browser and report what's working and what's broken.
|
|
594
|
+
${what ? `\nFocus on: ${what}` : ""}
|
|
595
|
+
${credentials ? `\nTest credentials: ${credentials}` : ""}
|
|
596
|
+
|
|
597
|
+
## Tool selection rule
|
|
598
|
+
|
|
599
|
+
- Prefer existing tools first: code search, git diff, logs, APIs, local files, and other MCP integrations. Gather all context you can before opening the browser.
|
|
600
|
+
- Use Hanzi only for browser-required steps: real UI interaction, visual verification, form submission, and anything that needs a rendered page.
|
|
601
|
+
- If a browser step could mutate real data, ask me before proceeding unless the environment is clearly local, dev, test, or preview.
|
|
602
|
+
|
|
603
|
+
## Safety: Check the target before testing
|
|
604
|
+
|
|
605
|
+
Browser tests create real state (signups, form submissions, orders). Before executing:
|
|
606
|
+
|
|
607
|
+
**Safe URLs (proceed without extra confirmation):** localhost, 127.0.0.1, 0.0.0.0, URLs with dev./staging./preview./.local, Vercel/Netlify preview URLs.
|
|
608
|
+
|
|
609
|
+
**Production or unknown URLs:** Ask me first: "This looks like a production URL. Should I test with real interactions (may create data), or stay read-only (just navigate and observe)?" Default to read-only if I don't answer.
|
|
610
|
+
|
|
611
|
+
**Credentials from .env:** Tell me what you found ("Found admin@test.com in .env.local") and confirm before using on non-local targets.
|
|
612
|
+
|
|
613
|
+
## Phase 1: Gather context BEFORE opening the browser
|
|
614
|
+
|
|
615
|
+
You have access to the codebase. Use it. Before touching the browser:
|
|
616
|
+
|
|
617
|
+
1. **Check what changed recently**: Run \`git diff --name-only HEAD~3\` or \`git log --oneline -5\` to see recent changes. This tells you what's most likely to be broken.
|
|
618
|
+
|
|
619
|
+
2. **Understand the app structure**: Look at routes, pages, or components to know what flows exist. Check for:
|
|
620
|
+
- Route definitions (e.g., Next.js \`app/\` directory, React Router config, Express routes)
|
|
621
|
+
- Key pages: login, signup, dashboard, checkout, settings
|
|
622
|
+
- API endpoints the frontend calls
|
|
623
|
+
|
|
624
|
+
3. **Find test credentials**: Check \`.env\`, \`.env.local\`, \`seed\` files, or test fixtures for test accounts. Note what type of account you found (admin, test user, etc.) — don't silently use production credentials.
|
|
625
|
+
|
|
626
|
+
4. **Check if the server is running**: Run \`curl -s -o /dev/null -w "%{http_code}" ${url}\`. If it's not running, tell me to start it and stop here.
|
|
627
|
+
|
|
628
|
+
5. **Decide what to test**: Based on recent changes + app structure, prioritize:
|
|
629
|
+
- Changed files first — if I touched the checkout page, test checkout
|
|
630
|
+
- Critical paths — signup, login, core feature
|
|
631
|
+
- If I said "everything", hit every major route
|
|
632
|
+
|
|
633
|
+
Present your test plan briefly: "I'll test: 1) signup, 2) login, 3) the checkout flow you changed in the last commit." Ask if I want to adjust before proceeding.
|
|
634
|
+
|
|
635
|
+
## Phase 2: Execute tests in the browser
|
|
636
|
+
|
|
637
|
+
Use \`browser_start\` for each flow. Test them **one at a time, sequentially**.
|
|
638
|
+
|
|
639
|
+
For each flow:
|
|
640
|
+
- Open the URL and navigate to the relevant page
|
|
641
|
+
- Interact like a real user: fill forms with realistic test data, click buttons, wait for responses
|
|
642
|
+
- Look for: broken layouts, missing elements, error messages, loading spinners that never stop, 404s, console errors visible on page
|
|
643
|
+
- Take note of what works AND what doesn't
|
|
644
|
+
|
|
645
|
+
**Important**: Tell the browser agent to be specific about what it sees. Not "the page looks fine" but "the signup form has 3 fields (name, email, password), I filled them in, clicked Submit, and was redirected to /dashboard with a welcome message."
|
|
646
|
+
|
|
647
|
+
If a flow requires login, log in first using the credentials I provided or that you found (with my confirmation).
|
|
648
|
+
|
|
649
|
+
If something fails, try to get specific error information — what error message appeared? What was the URL? What was the last thing that worked?
|
|
650
|
+
|
|
651
|
+
**After each \`browser_start\` returns**, call \`browser_screenshot\` (a separate MCP tool) to capture the final state. The browser window stays open, so the screenshot shows the page at the end of the flow. Do this for both passing and failing flows — screenshots are evidence.
|
|
652
|
+
|
|
653
|
+
## Phase 3: Report findings
|
|
654
|
+
|
|
655
|
+
After testing, write a clear report:
|
|
656
|
+
|
|
657
|
+
### Format:
|
|
658
|
+
\`\`\`
|
|
659
|
+
Tested [N] flows on ${url}:
|
|
660
|
+
|
|
661
|
+
✓ [Flow name] — [what happened, one line]
|
|
662
|
+
📸 Screenshot: [describe what the screenshot shows]
|
|
663
|
+
|
|
664
|
+
✗ [Flow name] — [what's broken, specifically]
|
|
665
|
+
📸 Screenshot: [what the page looked like when it failed]
|
|
666
|
+
|
|
667
|
+
⚠ [Flow name] — [works but has issues]
|
|
668
|
+
📸 Screenshot: [evidence of the issue]
|
|
669
|
+
\`\`\`
|
|
670
|
+
|
|
671
|
+
### Then, for each failure:
|
|
672
|
+
|
|
673
|
+
**Cross-reference with the code.** This is your superpower — you can see both the browser AND the codebase. For each broken thing:
|
|
674
|
+
1. What did the browser show? (include the screenshot)
|
|
675
|
+
2. What file likely causes this? (check recent git changes, route handlers, API endpoints)
|
|
676
|
+
3. What's your best guess at the root cause?
|
|
677
|
+
4. Suggest a fix if it's obvious.
|
|
678
|
+
|
|
679
|
+
Example:
|
|
680
|
+
\`\`\`
|
|
681
|
+
✗ Checkout — form submits but the page hangs on a loading spinner.
|
|
682
|
+
📸 Screenshot shows the payment form with a spinning loader, stuck for 30+ seconds.
|
|
683
|
+
|
|
684
|
+
Likely cause: src/api/checkout.ts was modified in your last commit (abc123).
|
|
685
|
+
You removed the \`onSuccess\` callback on line 45. The frontend is waiting
|
|
686
|
+
for a response that never comes.
|
|
687
|
+
|
|
688
|
+
Suggested fix: restore the onSuccess handler or add a redirect after
|
|
689
|
+
the API call resolves.
|
|
690
|
+
\`\`\`
|
|
691
|
+
|
|
692
|
+
### Summary:
|
|
693
|
+
- Total flows tested / passed / failed / warnings
|
|
694
|
+
- If everything passes: "All tested flows working. Ready to push."
|
|
695
|
+
- If there are failures: prioritize them by severity
|
|
696
|
+
|
|
697
|
+
## Rules
|
|
698
|
+
|
|
699
|
+
- Don't test in parallel — one flow at a time via separate browser_start calls
|
|
700
|
+
- Don't guess — if you can't tell what's wrong, say so and suggest I check manually
|
|
701
|
+
- Don't skip the codebase analysis — it's what makes your report actionable instead of generic
|
|
702
|
+
- If the dev server isn't running, stop and tell me instead of reporting "page not found" as a bug
|
|
703
|
+
- If browser_start times out, call browser_screenshot to see where it got stuck
|
|
704
|
+
- Always take a screenshot after each flow — for both passes and failures
|
|
705
|
+
- On production URLs, default to read-only unless I explicitly opt in
|
|
706
|
+
- Don't silently use credentials from .env on non-local targets — confirm first`,
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
};
|
|
711
|
+
},
|
|
712
|
+
"social-poster": (args) => {
|
|
713
|
+
const content = args.content || "";
|
|
714
|
+
const platforms = args.platforms || "linkedin, twitter";
|
|
715
|
+
const context = args.context || "";
|
|
716
|
+
return {
|
|
717
|
+
description: "Draft and post content across social platforms",
|
|
718
|
+
messages: [
|
|
719
|
+
{
|
|
720
|
+
role: "user",
|
|
721
|
+
content: {
|
|
722
|
+
type: "text",
|
|
723
|
+
text: `Post about this across social platforms: "${content}"
|
|
724
|
+
|
|
725
|
+
Platforms: ${platforms}
|
|
726
|
+
${context ? `\nExtra context: ${context}` : ""}
|
|
727
|
+
|
|
728
|
+
## Tool selection rule
|
|
729
|
+
|
|
730
|
+
- Prefer existing tools first: read the codebase, changelog, git log, README, or any source material to understand what to post about. Draft all content WITHOUT the browser.
|
|
731
|
+
- Use Hanzi only for the actual posting — opening each platform and submitting the post.
|
|
732
|
+
- Each post is a public action that cannot be undone. Show me every draft and get my approval before posting anything.
|
|
733
|
+
|
|
734
|
+
## Phase 1: Gather source material (no browser)
|
|
735
|
+
|
|
736
|
+
If I said something like "post about our latest release" or "post about the new feature":
|
|
737
|
+
1. Read the git log, changelog, README, or relevant files to understand what shipped
|
|
738
|
+
2. Identify the key points worth sharing
|
|
739
|
+
3. Find any links to include (docs, landing page, demo)
|
|
740
|
+
|
|
741
|
+
If I gave you the exact text, skip this and go to Phase 2.
|
|
742
|
+
|
|
743
|
+
## Phase 2: Draft per platform (no browser)
|
|
744
|
+
|
|
745
|
+
Write a separate version for each platform. Do NOT copy-paste the same text everywhere. Each platform has its own voice:
|
|
746
|
+
|
|
747
|
+
**LinkedIn:**
|
|
748
|
+
- Professional but not corporate. Storytelling works well.
|
|
749
|
+
- 1000-1500 chars ideal (can go up to 3000)
|
|
750
|
+
- Use line breaks for readability
|
|
751
|
+
- 3-5 hashtags at the end
|
|
752
|
+
- Include a link if relevant
|
|
753
|
+
- Bold key phrases using unicode (𝗯𝗼𝗹𝗱) sparingly
|
|
754
|
+
|
|
755
|
+
**Twitter/X:**
|
|
756
|
+
- Casual, punchy, opinionated
|
|
757
|
+
- Single tweet: under 280 chars
|
|
758
|
+
- If the content is too rich for one tweet, suggest a thread (number each tweet)
|
|
759
|
+
- 1-2 hashtags max, or none
|
|
760
|
+
- Link at the end
|
|
761
|
+
|
|
762
|
+
**Reddit:**
|
|
763
|
+
- Technical, no-BS, no marketing speak. Redditors hate self-promotion.
|
|
764
|
+
- Suggest the right subreddit (e.g., r/programming, r/webdev, r/machinelearning)
|
|
765
|
+
- Title should be informative, not clickbait
|
|
766
|
+
- Body in markdown
|
|
767
|
+
- If it's a project launch, frame it as "Show r/subreddit: ..."
|
|
768
|
+
- Be genuine about what it is and what it isn't
|
|
769
|
+
|
|
770
|
+
**Hacker News:**
|
|
771
|
+
- Ultra-minimal. Title + URL only.
|
|
772
|
+
- Title should be factual, not hypey ("Show HN: Tool that does X" format)
|
|
773
|
+
- No emoji, no exclamation marks
|
|
774
|
+
- Let the work speak for itself
|
|
775
|
+
|
|
776
|
+
**Product Hunt:**
|
|
777
|
+
- Launch-style: tagline + description + feature bullets
|
|
778
|
+
- Tagline: one punchy line under 60 chars
|
|
779
|
+
- Description: 2-3 sentences
|
|
780
|
+
- 3-5 key features as bullet points
|
|
781
|
+
|
|
782
|
+
### Show me all drafts in a clear format:
|
|
783
|
+
|
|
784
|
+
\`\`\`
|
|
785
|
+
--- LinkedIn ---
|
|
786
|
+
[draft text]
|
|
787
|
+
|
|
788
|
+
--- Twitter/X ---
|
|
789
|
+
[draft text]
|
|
790
|
+
|
|
791
|
+
--- Reddit (r/subreddit) ---
|
|
792
|
+
Title: [title]
|
|
793
|
+
Body: [draft text]
|
|
794
|
+
\`\`\`
|
|
795
|
+
|
|
796
|
+
Ask: "Ready to post these, or want to change anything?"
|
|
797
|
+
|
|
798
|
+
Do NOT proceed to posting until I confirm.
|
|
799
|
+
|
|
800
|
+
## Phase 3: Post (browser via Hanzi)
|
|
801
|
+
|
|
802
|
+
After I approve, post to each platform **one at a time, sequentially** using separate \`browser_start\` calls.
|
|
803
|
+
|
|
804
|
+
For each platform:
|
|
805
|
+
- Navigate to the platform (user is already logged in)
|
|
806
|
+
- Find the "new post" / "compose" area
|
|
807
|
+
- Paste the approved text
|
|
808
|
+
- Add any images or links if relevant
|
|
809
|
+
- Submit the post
|
|
810
|
+
- After \`browser_start\` returns, call \`browser_screenshot\` (a separate MCP tool) to capture the live post — the window stays open
|
|
811
|
+
- Note the URL of the published post if visible
|
|
812
|
+
|
|
813
|
+
If a platform requires additional steps (e.g., Reddit asks for a flair, Product Hunt needs a schedule), tell me and ask how to proceed.
|
|
814
|
+
|
|
815
|
+
If posting fails (CAPTCHA, rate limit, account restriction), skip that platform and report it.
|
|
816
|
+
|
|
817
|
+
## Phase 4: Report
|
|
818
|
+
|
|
819
|
+
\`\`\`
|
|
820
|
+
Posted to [N]/[total] platforms:
|
|
821
|
+
|
|
822
|
+
✓ LinkedIn — posted
|
|
823
|
+
📸 Screenshot of live post
|
|
824
|
+
URL: [url if available]
|
|
825
|
+
|
|
826
|
+
✓ Twitter/X — posted (2-tweet thread)
|
|
827
|
+
📸 Screenshot of live post
|
|
828
|
+
URL: [url if available]
|
|
829
|
+
|
|
830
|
+
✗ Reddit — r/programming requires account age > 30 days. Skipped.
|
|
831
|
+
\`\`\`
|
|
832
|
+
|
|
833
|
+
## Rules
|
|
834
|
+
|
|
835
|
+
- Never post without my explicit approval of the draft
|
|
836
|
+
- Never post to a platform I didn't ask for
|
|
837
|
+
- Don't use the same text across platforms — adapt each one
|
|
838
|
+
- If a platform blocks the post, don't retry — report and move on
|
|
839
|
+
- If browser_start times out, call browser_screenshot to see where it got stuck, then browser_message to continue
|
|
840
|
+
- Don't post images unless I provided them or explicitly asked for them
|
|
841
|
+
- One platform at a time, sequentially — not in parallel`,
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
};
|
|
846
|
+
},
|
|
847
|
+
"x-marketer": (args) => {
|
|
848
|
+
const product = args.product || "";
|
|
849
|
+
const keywords = args.keywords || "";
|
|
850
|
+
const mode = args.mode || "conversations";
|
|
851
|
+
const count = args.count || "10";
|
|
852
|
+
const context = args.context || "";
|
|
853
|
+
// Read the SKILL.md file for the full workflow
|
|
854
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
855
|
+
const skillPath = join(__dirname, "..", "skills", "x-marketer", "SKILL.md");
|
|
856
|
+
let skillContent = "";
|
|
857
|
+
try {
|
|
858
|
+
const raw = readFileSync(skillPath, "utf-8");
|
|
859
|
+
// Strip YAML frontmatter
|
|
860
|
+
skillContent = raw.replace(/^---[\s\S]*?---\n*/, "");
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
skillContent = "Error: Could not read x-marketer SKILL.md. Make sure the file exists at server/skills/x-marketer/SKILL.md";
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
description: "Find X/Twitter conversations and draft voice-matched replies",
|
|
867
|
+
messages: [
|
|
868
|
+
{
|
|
869
|
+
role: "user",
|
|
870
|
+
content: {
|
|
871
|
+
type: "text",
|
|
872
|
+
text: `Run the x-marketer skill.
|
|
873
|
+
|
|
874
|
+
Product: ${product}
|
|
875
|
+
Mode: ${mode}
|
|
876
|
+
Keywords: ${keywords}
|
|
877
|
+
Count: ${count}
|
|
878
|
+
${context ? `Extra context: ${context}` : ""}
|
|
879
|
+
|
|
880
|
+
${skillContent}`,
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
};
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
|
|
888
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
889
|
+
const { name, arguments: args } = request.params;
|
|
890
|
+
const template = PROMPT_TEMPLATES[name];
|
|
891
|
+
if (!template) {
|
|
892
|
+
throw new Error(`Unknown prompt: ${name}`);
|
|
893
|
+
}
|
|
894
|
+
return template(args || {});
|
|
895
|
+
});
|
|
896
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
897
|
+
const { name, arguments: args } = request.params;
|
|
898
|
+
try {
|
|
899
|
+
switch (name) {
|
|
900
|
+
case "browser_start": {
|
|
901
|
+
const task = args?.task;
|
|
902
|
+
const url = args?.url;
|
|
903
|
+
const context = args?.context;
|
|
904
|
+
if (!task?.trim()) {
|
|
905
|
+
return { content: [{ type: "text", text: "Error: task cannot be empty" }], isError: true };
|
|
906
|
+
}
|
|
907
|
+
// --- Managed proxy mode: forward to api.hanzilla.co ---
|
|
908
|
+
if (IS_MANAGED_MODE) {
|
|
909
|
+
console.error(`[MCP] Managed mode — proxying task to ${MANAGED_API_URL}`);
|
|
910
|
+
const result = await runManagedTask(task, url, context);
|
|
911
|
+
return {
|
|
912
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
913
|
+
isError: result.status === "error",
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
// --- Local BYOM mode ---
|
|
917
|
+
// Check license / usage limit
|
|
918
|
+
const usage = await checkAndIncrementUsage();
|
|
919
|
+
if (!usage.allowed) {
|
|
920
|
+
return { content: [{ type: "text", text: usage.message }], isError: true };
|
|
921
|
+
}
|
|
922
|
+
console.error(`[MCP] ${usage.message}`);
|
|
923
|
+
// Check credentials before starting
|
|
924
|
+
const creds = resolveCredentials();
|
|
925
|
+
if (!creds) {
|
|
926
|
+
return {
|
|
927
|
+
content: [{
|
|
928
|
+
type: "text",
|
|
929
|
+
text: "No LLM credentials found. Set ANTHROPIC_API_KEY env var or run `claude login`.",
|
|
930
|
+
}],
|
|
931
|
+
isError: true,
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
// Pre-flight: check if extension is connected
|
|
935
|
+
if (!await isExtensionConnected()) {
|
|
936
|
+
openInBrowser(EXTENSION_URL);
|
|
937
|
+
return {
|
|
938
|
+
content: [{
|
|
939
|
+
type: "text",
|
|
940
|
+
text: `Chrome extension is not connected. Opening install page in your browser.\n\nIf already installed, make sure Chrome is open and the extension is enabled. Then try again.`,
|
|
941
|
+
}],
|
|
942
|
+
isError: true,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
// Check concurrency
|
|
946
|
+
const activeCount = [...sessions.values()].filter((s) => s.status === "running").length;
|
|
947
|
+
if (activeCount >= MAX_CONCURRENT) {
|
|
948
|
+
return {
|
|
949
|
+
content: [{
|
|
950
|
+
type: "text",
|
|
951
|
+
text: `Too many parallel tasks (${activeCount}/${MAX_CONCURRENT}). Wait for some to complete or stop them first.`,
|
|
952
|
+
}],
|
|
953
|
+
isError: true,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
const session = {
|
|
957
|
+
id: randomUUID().slice(0, 8),
|
|
958
|
+
task,
|
|
959
|
+
url,
|
|
960
|
+
context,
|
|
961
|
+
status: "running",
|
|
962
|
+
steps: [],
|
|
963
|
+
createdAt: Date.now(),
|
|
964
|
+
updatedAt: Date.now(),
|
|
965
|
+
};
|
|
966
|
+
sessions.set(session.id, session);
|
|
967
|
+
console.error(`[MCP] Starting task ${session.id}: ${task.slice(0, 80)}`);
|
|
968
|
+
const completionPromise = waitForSessionTerminal(session.id);
|
|
969
|
+
await send({
|
|
970
|
+
type: "mcp_start_task",
|
|
971
|
+
sessionId: session.id,
|
|
972
|
+
task,
|
|
973
|
+
url,
|
|
974
|
+
context,
|
|
975
|
+
});
|
|
976
|
+
const result = await completionPromise;
|
|
977
|
+
if (result === null) {
|
|
978
|
+
session.status = "timeout";
|
|
979
|
+
session.error = `Task still running after ${TASK_TIMEOUT_MS / 60000} minutes. Use browser_screenshot to check progress, then browser_message to continue or browser_stop to end.`;
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
content: [{ type: "text", text: JSON.stringify(formatResult(session), null, 2) }],
|
|
983
|
+
isError: session.status === "error",
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
case "browser_message": {
|
|
987
|
+
const sessionId = args?.session_id;
|
|
988
|
+
const message = args?.message;
|
|
989
|
+
const session = sessions.get(sessionId);
|
|
990
|
+
if (!session) {
|
|
991
|
+
return { content: [{ type: "text", text: `Session not found: ${sessionId}` }], isError: true };
|
|
992
|
+
}
|
|
993
|
+
if (!message?.trim()) {
|
|
994
|
+
return { content: [{ type: "text", text: "Error: message cannot be empty" }], isError: true };
|
|
995
|
+
}
|
|
996
|
+
session.status = "running";
|
|
997
|
+
session.answer = undefined;
|
|
998
|
+
session.error = undefined;
|
|
999
|
+
session.updatedAt = Date.now();
|
|
1000
|
+
console.error(`[MCP] Message to ${sessionId}: ${message.slice(0, 80)}`);
|
|
1001
|
+
const completionPromise = waitForSessionTerminal(session.id);
|
|
1002
|
+
await send({
|
|
1003
|
+
type: "mcp_send_message",
|
|
1004
|
+
sessionId: session.id,
|
|
1005
|
+
message,
|
|
1006
|
+
});
|
|
1007
|
+
const result = await completionPromise;
|
|
1008
|
+
if (result === null) {
|
|
1009
|
+
session.status = "timeout";
|
|
1010
|
+
session.error = `Task still running after ${TASK_TIMEOUT_MS / 60000} minutes.`;
|
|
1011
|
+
}
|
|
1012
|
+
const latestSession = sessions.get(session.id) || session;
|
|
1013
|
+
return {
|
|
1014
|
+
content: [{ type: "text", text: JSON.stringify(formatResult(latestSession), null, 2) }],
|
|
1015
|
+
isError: latestSession.status === "error",
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
case "browser_status": {
|
|
1019
|
+
const sessionId = args?.session_id;
|
|
1020
|
+
if (sessionId) {
|
|
1021
|
+
const session = sessions.get(sessionId);
|
|
1022
|
+
if (!session) {
|
|
1023
|
+
return { content: [{ type: "text", text: `Session not found: ${sessionId}` }], isError: true };
|
|
1024
|
+
}
|
|
1025
|
+
return { content: [{ type: "text", text: JSON.stringify(formatResult(session), null, 2) }] };
|
|
1026
|
+
}
|
|
1027
|
+
const all = [...sessions.values()].map(formatResult);
|
|
1028
|
+
return { content: [{ type: "text", text: JSON.stringify(all, null, 2) }] };
|
|
1029
|
+
}
|
|
1030
|
+
case "browser_stop": {
|
|
1031
|
+
const sessionId = args?.session_id;
|
|
1032
|
+
const session = sessions.get(sessionId);
|
|
1033
|
+
if (!session) {
|
|
1034
|
+
return { content: [{ type: "text", text: `Session not found: ${sessionId}` }], isError: true };
|
|
1035
|
+
}
|
|
1036
|
+
await send({ type: "mcp_stop_task", sessionId, remove: args?.remove === true });
|
|
1037
|
+
if (args?.remove) {
|
|
1038
|
+
sessions.delete(sessionId);
|
|
1039
|
+
return { content: [{ type: "text", text: `Session ${sessionId} removed.` }] };
|
|
1040
|
+
}
|
|
1041
|
+
session.status = "stopped";
|
|
1042
|
+
return { content: [{ type: "text", text: `Session ${sessionId} stopped.` }] };
|
|
1043
|
+
}
|
|
1044
|
+
case "browser_screenshot": {
|
|
1045
|
+
const sessionId = args?.session_id;
|
|
1046
|
+
const requestId = sessionId || `screenshot-${Date.now()}`;
|
|
1047
|
+
const screenshotPromise = new Promise((resolve) => {
|
|
1048
|
+
const timeout = setTimeout(() => {
|
|
1049
|
+
pendingScreenshots.delete(requestId);
|
|
1050
|
+
resolve(null);
|
|
1051
|
+
}, 5000);
|
|
1052
|
+
pendingScreenshots.set(requestId, { resolve, timeout });
|
|
1053
|
+
});
|
|
1054
|
+
await send({ type: "mcp_screenshot", sessionId: requestId });
|
|
1055
|
+
const data = await screenshotPromise;
|
|
1056
|
+
if (data) {
|
|
1057
|
+
return {
|
|
1058
|
+
content: [
|
|
1059
|
+
{ type: "image", data, mimeType: "image/png" },
|
|
1060
|
+
{ type: "text", text: "Screenshot of current browser state" },
|
|
1061
|
+
],
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
return { content: [{ type: "text", text: "Screenshot timed out." }], isError: true };
|
|
1065
|
+
}
|
|
1066
|
+
default:
|
|
1067
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
catch (error) {
|
|
1071
|
+
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
// --- Startup ---
|
|
1075
|
+
async function main() {
|
|
1076
|
+
console.error("[MCP] Starting Hanzi Browse MCP Server v2.0...");
|
|
1077
|
+
if (IS_MANAGED_MODE) {
|
|
1078
|
+
console.error(`[MCP] Mode: MANAGED (proxying tasks to ${MANAGED_API_URL})`);
|
|
1079
|
+
console.error(`[MCP] API key: ${MANAGED_API_KEY.slice(0, 20)}...`);
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
console.error("[MCP] Mode: BYOM (local agent loop)");
|
|
1083
|
+
// Startup diagnostics
|
|
1084
|
+
const credDesc = describeCredentials();
|
|
1085
|
+
console.error(`[MCP] Credentials: ${credDesc}`);
|
|
1086
|
+
const licenseStatus = getLicenseStatus();
|
|
1087
|
+
console.error(`[MCP] License: ${licenseStatus.message}`);
|
|
1088
|
+
}
|
|
1089
|
+
connection = new WebSocketClient({
|
|
1090
|
+
role: "mcp",
|
|
1091
|
+
autoStartRelay: true,
|
|
1092
|
+
onDisconnect: () => console.error("[MCP] Relay disconnected, will reconnect"),
|
|
1093
|
+
});
|
|
1094
|
+
connection.onMessage(handleMessage);
|
|
1095
|
+
await connection.connect();
|
|
1096
|
+
console.error("[MCP] Connected to relay");
|
|
1097
|
+
// Quick extension check at startup (single probe, no retries — don't block startup)
|
|
1098
|
+
try {
|
|
1099
|
+
if (await checkExtensionOnce()) {
|
|
1100
|
+
console.error("[MCP] Extension connected — ready for tasks");
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
console.error("[MCP] Extension not connected — will retry when tasks arrive");
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
// Non-fatal — don't block startup
|
|
1108
|
+
}
|
|
1109
|
+
const transport = new StdioServerTransport();
|
|
1110
|
+
await server.connect(transport);
|
|
1111
|
+
console.error("[MCP] Server running (browser execution: extension-side)");
|
|
1112
|
+
}
|
|
1113
|
+
main().catch((error) => {
|
|
1114
|
+
console.error("[MCP] Fatal:", error);
|
|
1115
|
+
process.exit(1);
|
|
1116
|
+
});
|