effing-use 0.1.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.
@@ -0,0 +1,31 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Config } from "../config.js";
4
+
5
+ export async function saveText(
6
+ config: Config,
7
+ filename: string,
8
+ text: string,
9
+ ): Promise<string> {
10
+ await mkdir(config.outputDir, { recursive: true });
11
+ const path = join(config.outputDir, filename);
12
+ await writeFile(path, text, "utf-8");
13
+ return path;
14
+ }
15
+
16
+ export function cap(
17
+ text: string,
18
+ maxChars: number,
19
+ ): { text: string; truncated: boolean } {
20
+ if (text.length <= maxChars) return { text, truncated: false };
21
+ return {
22
+ text:
23
+ text.slice(0, maxChars) +
24
+ `\n…[truncated ${text.length - maxChars} chars, see file]`,
25
+ truncated: true,
26
+ };
27
+ }
28
+
29
+ export function stamp(prefix: string, ext: string): string {
30
+ return `${prefix}-${new Date().toISOString().replace(/[:.]/g, "-")}.${ext}`;
31
+ }
@@ -0,0 +1,75 @@
1
+ import type { Page, Locator } from "playwright";
2
+
3
+ export class EngineError extends Error {
4
+ code: string;
5
+ hint: string;
6
+ data?: Record<string, unknown>;
7
+ constructor(
8
+ code: string,
9
+ message: string,
10
+ hint: string,
11
+ data?: Record<string, unknown>,
12
+ ) {
13
+ super(message);
14
+ this.code = code;
15
+ this.hint = hint;
16
+ this.data = data;
17
+ }
18
+ }
19
+
20
+ const INTERACTIVE_SELECTOR =
21
+ "button, a, input, select, textarea, [role=button], [tabindex]";
22
+
23
+ /** Resolve `eNN` snapshot refs via nth-match on interactive elements. */
24
+ export function resolveRef(page: Page, ref: string): Locator {
25
+ const m = /^e(\d+)$/i.exec(ref.trim());
26
+ if (!m) throw new Error("not an e-ref");
27
+ return page.locator(INTERACTIVE_SELECTOR).nth(Number(m[1]));
28
+ }
29
+
30
+ export async function resolveLocator(
31
+ page: Page,
32
+ target: string,
33
+ ): Promise<Locator> {
34
+ const t = target.trim();
35
+ // 0. eNN ref
36
+ if (/^e\d+$/i.test(t)) {
37
+ const loc = resolveRef(page, t);
38
+ if ((await loc.count()) > 0) return loc;
39
+ throw new EngineError(
40
+ "E_NOT_FOUND",
41
+ `No element matches "${t}".`,
42
+ "Call browser_observe kind=snapshot first to refresh refs, then use a fresh e-ref.",
43
+ );
44
+ }
45
+ // 1. role= selector passes through
46
+ if (t.startsWith("role=")) return page.locator(t);
47
+ // 2. CSS-ish (starts with #, ., [, //, or tag)
48
+ if (/^[#.\[/a-zA-Z]/.test(t) && !t.includes(" ")) {
49
+ try {
50
+ const css = page.locator(`css=${t}`);
51
+ if ((await css.count()) > 0) return css.first();
52
+ } catch {
53
+ /* fall through */
54
+ }
55
+ }
56
+ // try as generic CSS anyway
57
+ try {
58
+ const any = page.locator(t);
59
+ if ((await any.count()) > 0) return any.first();
60
+ } catch {
61
+ /* fall through to fuzzy */
62
+ }
63
+ // 3. fuzzy label on interactive roles
64
+ const fuzzy = page
65
+ .getByRole("button", { name: t })
66
+ .or(page.getByRole("link", { name: t }))
67
+ .or(page.getByRole("textbox", { name: t }))
68
+ .or(page.getByText(t));
69
+ if ((await fuzzy.count()) > 0) return fuzzy.first();
70
+ throw new EngineError(
71
+ "E_NOT_FOUND",
72
+ `No element matches "${t}".`,
73
+ "Call browser_observe kind=snapshot first, then use an e-ref, role= selector, or CSS selector.",
74
+ );
75
+ }
@@ -0,0 +1,100 @@
1
+ import {
2
+ chromium,
3
+ type Browser,
4
+ type BrowserContext,
5
+ type Page,
6
+ } from "playwright";
7
+ import type { Config } from "../config.js";
8
+
9
+ let browser: Browser | null = null;
10
+
11
+ interface SessionEntry {
12
+ context: BrowserContext;
13
+ page: Page;
14
+ consoleLogs: Array<{ type: string; text: string; at: string }>;
15
+ networkLogs: Array<{ method: string; url: string; status: number }>;
16
+ }
17
+
18
+ const sessions = new Map<string, SessionEntry>();
19
+
20
+ async function getBrowser(config: Config): Promise<Browser> {
21
+ if (!browser) {
22
+ browser = await chromium.launch({ headless: config.headless });
23
+ }
24
+ return browser;
25
+ }
26
+
27
+ export async function getPage(
28
+ config: Config,
29
+ sessionId = "default",
30
+ ): Promise<Page> {
31
+ const existing = sessions.get(sessionId);
32
+ if (existing) return existing.page;
33
+ const b = await getBrowser(config);
34
+ const context = await b.newContext({
35
+ viewport: { width: config.viewportW, height: config.viewportH },
36
+ });
37
+ const page = await context.newPage();
38
+ const entry: SessionEntry = {
39
+ context,
40
+ page,
41
+ consoleLogs: [],
42
+ networkLogs: [],
43
+ };
44
+ page.on("console", (msg) => {
45
+ entry.consoleLogs.push({
46
+ type: msg.type(),
47
+ text: msg.text(),
48
+ at: new Date().toISOString(),
49
+ });
50
+ if (entry.consoleLogs.length > 50) entry.consoleLogs.shift();
51
+ });
52
+ page.on("response", (res) => {
53
+ entry.networkLogs.push({
54
+ method: res.request().method(),
55
+ url: res.url(),
56
+ status: res.status(),
57
+ });
58
+ if (entry.networkLogs.length > 50) entry.networkLogs.shift();
59
+ });
60
+ sessions.set(sessionId, entry);
61
+ return page;
62
+ }
63
+
64
+ export async function getContext(
65
+ config: Config,
66
+ sessionId = "default",
67
+ ): Promise<BrowserContext> {
68
+ const existing = sessions.get(sessionId);
69
+ if (existing) return existing.context;
70
+ await getPage(config, sessionId);
71
+ return sessions.get(sessionId)!.context;
72
+ }
73
+
74
+ export function getConsoleLogs(
75
+ sessionId = "default",
76
+ ): SessionEntry["consoleLogs"] {
77
+ return sessions.get(sessionId)?.consoleLogs ?? [];
78
+ }
79
+
80
+ export function getNetworkLogs(
81
+ sessionId = "default",
82
+ ): SessionEntry["networkLogs"] {
83
+ return sessions.get(sessionId)?.networkLogs ?? [];
84
+ }
85
+
86
+ export async function closeSession(sessionId = "default"): Promise<void> {
87
+ const s = sessions.get(sessionId);
88
+ if (s) {
89
+ await s.context.close();
90
+ sessions.delete(sessionId);
91
+ }
92
+ }
93
+
94
+ export async function closeAll(): Promise<void> {
95
+ for (const id of [...sessions.keys()]) await closeSession(id);
96
+ if (browser) {
97
+ await browser.close();
98
+ browser = null;
99
+ }
100
+ }
package/src/config.ts ADDED
@@ -0,0 +1,25 @@
1
+ import * as v from "valibot";
2
+
3
+ const ConfigSchema = v.object({
4
+ headless: v.optional(v.boolean(), true),
5
+ viewportW: v.optional(v.number(), 1280),
6
+ viewportH: v.optional(v.number(), 800),
7
+ timeoutMs: v.optional(v.number(), 15000),
8
+ outputDir: v.optional(v.string(), ".browser-use"),
9
+ outputMaxChars: v.optional(v.number(), 4000),
10
+ allowEval: v.optional(v.boolean(), false),
11
+ });
12
+
13
+ export type Config = v.InferOutput<typeof ConfigSchema>;
14
+
15
+ export function loadConfig(): Config {
16
+ return v.parse(ConfigSchema, {
17
+ headless: process.env.BROWSER_HEADLESS !== "false",
18
+ viewportW: Number(process.env.BROWSER_VIEWPORT_W ?? 1280),
19
+ viewportH: Number(process.env.BROWSER_VIEWPORT_H ?? 800),
20
+ timeoutMs: Number(process.env.BROWSER_TIMEOUT_MS ?? 15000),
21
+ outputDir: process.env.OUTPUT_DIR ?? ".browser-use",
22
+ outputMaxChars: Number(process.env.OUTPUT_MAX_CHARS ?? 4000),
23
+ allowEval: process.env.ALLOW_EVAL === "true",
24
+ });
25
+ }
package/src/http.ts ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bun
2
+ import { HttpTransport } from "@tmcp/transport-http";
3
+ import { server } from "./server.js";
4
+
5
+ const port = Number(process.env.PORT ?? 3000);
6
+
7
+ // Streamable HTTP transport (MCP spec). Serves the MCP endpoint at /mcp.
8
+ // No auth on local loopback; put a reverse proxy / tunnel in front for remote use.
9
+ // Bun-only: Bun.serve keeps this file dependency-free (no node:http shim).
10
+ const transport = new HttpTransport(server, { path: "/mcp" });
11
+
12
+ Bun.serve({
13
+ port,
14
+ async fetch(req) {
15
+ const url = new URL(req.url);
16
+ if (url.pathname === "/healthz") {
17
+ return Response.json({ ok: true, name: "effing-use" });
18
+ }
19
+ const response = await transport.respond(req);
20
+ if (response === null) {
21
+ return new Response("Not Found", { status: 404 });
22
+ }
23
+ return response;
24
+ },
25
+ });
26
+
27
+ // eslint-disable-next-line no-console
28
+ console.log(`effing-use listening on :${port} (MCP at /mcp)`);
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bun
2
+ import { StdioTransport } from "@tmcp/transport-stdio";
3
+ import { server } from "./server.js";
4
+
5
+ const transport = new StdioTransport(server);
6
+ transport.listen();
package/src/server.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { McpServer } from "tmcp";
2
+ import { ValibotJsonSchemaAdapter } from "@tmcp/adapter-valibot";
3
+ import { actTool } from "./tools/act.js";
4
+ import { observeTool } from "./tools/observe.js";
5
+ import { extractTool } from "./tools/extract.js";
6
+
7
+ const adapter = new ValibotJsonSchemaAdapter();
8
+
9
+ export const server = new McpServer(
10
+ {
11
+ name: "effing-use",
12
+ version: "0.1.0",
13
+ description:
14
+ "Token-efficient browser control: 3 tools (act, observe, extract).",
15
+ },
16
+ { adapter, capabilities: { tools: { listChanged: true } } },
17
+ );
18
+
19
+ server.tools([actTool, observeTool, extractTool]);
@@ -0,0 +1,131 @@
1
+ import { defineTool } from "tmcp/tool";
2
+ import { tool } from "tmcp/utils";
3
+ import * as v from "valibot";
4
+ import { loadConfig } from "../config.js";
5
+ import { getPage, getContext } from "../browser/session.js";
6
+ import { doAct, doBatch, doGoal, type ActAction } from "../browser/engine.js";
7
+ import { EngineError } from "../browser/refs.js";
8
+
9
+ export const ACT_ACTIONS = [
10
+ "open",
11
+ "goto",
12
+ "click",
13
+ "dblclick",
14
+ "fill",
15
+ "type",
16
+ "press",
17
+ "select",
18
+ "check",
19
+ "uncheck",
20
+ "hover",
21
+ "drag",
22
+ "upload",
23
+ "scroll",
24
+ "back",
25
+ "forward",
26
+ "reload",
27
+ "wait",
28
+ "dialog_accept",
29
+ "dialog_dismiss",
30
+ "close",
31
+ "goal",
32
+ "batch",
33
+ "tab_new",
34
+ "tab_select",
35
+ "tab_close",
36
+ "resize",
37
+ ] as const;
38
+
39
+ const StepSchema = v.object({
40
+ action: v.picklist(ACT_ACTIONS),
41
+ target: v.optional(v.string()),
42
+ value: v.optional(v.string()),
43
+ });
44
+
45
+ export const actTool = defineTool(
46
+ {
47
+ name: "browser_act",
48
+ description:
49
+ "Drive the browser: open/goto URLs, click, fill, type, press keys, select, check, hover, drag, upload, scroll, back/forward/reload, wait, dialogs, tabs, resize, or run a high-level goal or batch of steps. Targets accept snapshot e-refs (e12), role= selectors, or CSS. Returns a capped JSON summary; large state goes to a file path.",
50
+ schema: v.object({
51
+ action: v.picklist(ACT_ACTIONS),
52
+ target: v.optional(v.string()),
53
+ value: v.optional(v.string()),
54
+ sessionId: v.optional(v.string()),
55
+ steps: v.optional(v.array(StepSchema)),
56
+ }),
57
+ annotations: {
58
+ readOnlyHint: false,
59
+ destructiveHint: false,
60
+ idempotentHint: false,
61
+ openWorldHint: true,
62
+ },
63
+ },
64
+ async ({ action, target, value, sessionId, steps }) => {
65
+ const config = loadConfig();
66
+ const sid = sessionId ?? "default";
67
+ try {
68
+ const page = await getPage(config, sid);
69
+ const context = await getContext(config, sid);
70
+ const opts = { context, sessionId: sid };
71
+ if (action === "batch") {
72
+ if (!steps || steps.length === 0)
73
+ return tool.text(
74
+ JSON.stringify({
75
+ ok: false,
76
+ code: "E_BAD_INPUT",
77
+ message: "Missing steps[].",
78
+ hint: "Pass steps[] with up to 20 {action,target,value} entries.",
79
+ }),
80
+ );
81
+ const result = await doBatch(
82
+ page,
83
+ config,
84
+ steps as Array<{
85
+ action: ActAction;
86
+ target?: string;
87
+ value?: string;
88
+ }>,
89
+ opts,
90
+ );
91
+ const url = page.url();
92
+ const title = await page.title().catch(() => "");
93
+ return tool.text(
94
+ JSON.stringify({ ok: true, action, url, title, ...result }),
95
+ );
96
+ }
97
+ if (action === "goal") {
98
+ const result = await doGoal(page, config, value ?? target ?? "", opts);
99
+ return tool.text(JSON.stringify({ ok: true, action, ...result }));
100
+ }
101
+ const result = await doAct(
102
+ page,
103
+ config,
104
+ action as ActAction,
105
+ target,
106
+ value,
107
+ opts,
108
+ );
109
+ return tool.text(JSON.stringify({ ok: true, action, ...result }));
110
+ } catch (e) {
111
+ if (e instanceof EngineError)
112
+ return tool.text(
113
+ JSON.stringify({
114
+ ok: false,
115
+ code: e.code,
116
+ message: e.message,
117
+ hint: e.hint,
118
+ ...(e.data ?? {}),
119
+ }),
120
+ );
121
+ return tool.text(
122
+ JSON.stringify({
123
+ ok: false,
124
+ code: "E_BAD_INPUT",
125
+ message: e instanceof Error ? e.message : String(e),
126
+ hint: "Retry with fresh snapshot refs.",
127
+ }),
128
+ );
129
+ }
130
+ },
131
+ );
@@ -0,0 +1,86 @@
1
+ import { defineTool } from "tmcp/tool";
2
+ import { tool } from "tmcp/utils";
3
+ import * as v from "valibot";
4
+ import { loadConfig } from "../config.js";
5
+ import { getPage, getContext } from "../browser/session.js";
6
+ import { doExtract, doQuery } from "../browser/engine.js";
7
+ import { EngineError } from "../browser/refs.js";
8
+
9
+ export const extractTool = defineTool(
10
+ {
11
+ name: "browser_extract",
12
+ description:
13
+ "Pull structured data out of the page: rendered text, HTML, table rows as JSON, CSS query as JSON, PDF file, or Playwright trace. Large results go to a file path with a capped preview. Prefer over snapshot for scraping.",
14
+ schema: v.object({
15
+ kind: v.picklist([
16
+ "text",
17
+ "html",
18
+ "table",
19
+ "query",
20
+ "pdf",
21
+ "trace_start",
22
+ "trace_stop",
23
+ ]),
24
+ selector: v.optional(v.string()),
25
+ mode: v.optional(v.string()),
26
+ limit: v.optional(v.number()),
27
+ sessionId: v.optional(v.string()),
28
+ }),
29
+ annotations: {
30
+ readOnlyHint: true,
31
+ destructiveHint: false,
32
+ idempotentHint: true,
33
+ openWorldHint: true,
34
+ },
35
+ },
36
+ async ({ kind, selector, mode, limit, sessionId }) => {
37
+ const config = loadConfig();
38
+ const sid = sessionId ?? "default";
39
+ try {
40
+ const page = await getPage(config, sid);
41
+ const context = await getContext(config, sid);
42
+ if (kind === "query") {
43
+ if (!selector)
44
+ return tool.text(
45
+ JSON.stringify({
46
+ ok: false,
47
+ code: "E_BAD_INPUT",
48
+ message: "Missing selector.",
49
+ hint: "Pass a CSS selector in selector and mode text|href|json.",
50
+ }),
51
+ );
52
+ const result = await doQuery(
53
+ page,
54
+ config,
55
+ selector,
56
+ mode ?? "text",
57
+ limit ?? 50,
58
+ );
59
+ return tool.text(JSON.stringify({ ok: true, kind, ...result }));
60
+ }
61
+ const result = await doExtract(page, config, kind, selector, limit, {
62
+ context,
63
+ sessionId: sid,
64
+ });
65
+ return tool.text(JSON.stringify({ ok: true, kind, ...result }));
66
+ } catch (e) {
67
+ if (e instanceof EngineError)
68
+ return tool.text(
69
+ JSON.stringify({
70
+ ok: false,
71
+ code: e.code,
72
+ message: e.message,
73
+ hint: e.hint,
74
+ }),
75
+ );
76
+ return tool.text(
77
+ JSON.stringify({
78
+ ok: false,
79
+ code: "E_BAD_INPUT",
80
+ message: e instanceof Error ? e.message : String(e),
81
+ hint: "Retry the extract call.",
82
+ }),
83
+ );
84
+ }
85
+ },
86
+ );
@@ -0,0 +1,68 @@
1
+ import { defineTool } from "tmcp/tool";
2
+ import { tool } from "tmcp/utils";
3
+ import * as v from "valibot";
4
+ import { loadConfig } from "../config.js";
5
+ import { getPage, getContext } from "../browser/session.js";
6
+ import { doObserve } from "../browser/engine.js";
7
+ import { EngineError } from "../browser/refs.js";
8
+
9
+ export const observeTool = defineTool(
10
+ {
11
+ name: "browser_observe",
12
+ description:
13
+ "Read browser state without changing it: snapshot (element refs), screenshot (file path), url, title, console logs, network requests, tab list, focused element. Snapshots are capped; full content is saved to a file path. Call snapshot before act to get refs.",
14
+ schema: v.object({
15
+ kind: v.picklist([
16
+ "snapshot",
17
+ "screenshot",
18
+ "url",
19
+ "title",
20
+ "console",
21
+ "network",
22
+ "tabs",
23
+ "focused",
24
+ ]),
25
+ target: v.optional(v.string()),
26
+ limit: v.optional(v.number()),
27
+ sessionId: v.optional(v.string()),
28
+ inline: v.optional(v.boolean()),
29
+ }),
30
+ annotations: {
31
+ readOnlyHint: true,
32
+ destructiveHint: false,
33
+ idempotentHint: true,
34
+ openWorldHint: false,
35
+ },
36
+ },
37
+ async ({ kind, target, limit, sessionId }) => {
38
+ const config = loadConfig();
39
+ const sid = sessionId ?? "default";
40
+ try {
41
+ const page = await getPage(config, sid);
42
+ const context = await getContext(config, sid);
43
+ const result = await doObserve(page, config, kind, target, limit, {
44
+ context,
45
+ sessionId: sid,
46
+ });
47
+ return tool.text(JSON.stringify({ ok: true, kind, ...result }));
48
+ } catch (e) {
49
+ if (e instanceof EngineError)
50
+ return tool.text(
51
+ JSON.stringify({
52
+ ok: false,
53
+ code: e.code,
54
+ message: e.message,
55
+ hint: e.hint,
56
+ }),
57
+ );
58
+ return tool.text(
59
+ JSON.stringify({
60
+ ok: false,
61
+ code: "E_BAD_INPUT",
62
+ message: e instanceof Error ? e.message : String(e),
63
+ hint: "Retry the observe call.",
64
+ }),
65
+ );
66
+ }
67
+ },
68
+ );