@rebind.gg/mcp-server 0.1.6 → 0.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 +24 -8
- package/package.json +9 -6
- package/src/bookmarklet.js +182 -0
- package/src/index.ts +1237 -31
- package/src/keys.test.ts +15 -0
- package/src/keys.ts +31 -0
- package/src/llm.ts +157 -0
- package/src/prompts.ts +101 -0
- package/src/rebind.d.luau +95 -30
- package/src/session.ts +2 -1
package/README.md
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# @rebind.gg/mcp-server
|
|
2
2
|
|
|
3
|
-
Stdio MCP server that turns [Rebind](https://rebind.gg) into a computer-use toolkit: screenshots, verified clicks, keyboard input, window control, and direct Luau scripting on the connected machine
|
|
3
|
+
Stdio MCP server that turns [Rebind](https://rebind.gg) into a computer-use toolkit: screenshots, verified clicks, keyboard input, window control, and direct Luau scripting on the connected machine. Input uses the relay's active transport: genuine USB HID in hardware mode or OS injection in software mode.
|
|
4
4
|
|
|
5
5
|
**Requirements**
|
|
6
6
|
|
|
7
7
|
- [Bun](https://bun.sh) >= 1.1 (the server runs via `bunx`)
|
|
8
|
-
- The [Rebind app](https://rebind.gg/download) running
|
|
9
|
-
|
|
10
|
-
> Older copies of `remote_access.lua` called `hid.press` outside a coroutine, so clicks silently failed. Use `remote.luau` or a current `remote_access.lua`.
|
|
8
|
+
- The [Rebind app](https://rebind.gg/download) running the free [Remote Control](https://www.rebind.gg/marketplace/script_AAHteeDcYPGOT7GfXZ_OK) package (`rebind install @rebind/remote-control`)
|
|
11
9
|
|
|
12
10
|
## Install
|
|
13
11
|
|
|
@@ -56,19 +54,25 @@ Connects, takes a capture, and reports the display — the fastest way to catch
|
|
|
56
54
|
| `move_mouse` | Move without clicking, with the same verification. |
|
|
57
55
|
| `type` | Type text into the focused element. |
|
|
58
56
|
| `key` | Press a key or combo, e.g. `Enter`, `LCtrl+L`, `LAlt+Tab`. |
|
|
59
|
-
| `scroll` | Scroll the wheel
|
|
57
|
+
| `scroll` | Scroll at the cursor. Positive scrolls down, negative up. `unit` is `wheel` (default, fine wheel clicks) or `page` (one PageDown or PageUp per unit, capped at 10). |
|
|
58
|
+
| `read_page` | Extract the focused browser's current page as structured text (URL, forms, interactive elements, plain text) via an address-bar bookmarklet; the full dump, including cleaned HTML, lands in the clipboard. The result is read back through AppleScript (`URL of active tab of front window`), so the relay host must be macOS and the front browser must answer that query. Safari is detected but blocks the bookmarklet path. |
|
|
59
|
+
| `meditate` | Research a problem page until a reasoning model has the data to solve it: fetches the URL directly, pulls the script bundles behind a JS shell so its API endpoints are visible, fetches those endpoints and assets, and reads the live page with vision. Returns a markdown dossier plus JSON `{error, problem, requirements, plan, submittable, form, live_captcha_hint, confidence, sources, assets, pending_fetch, notes, cost_usd}`. `error` is non-null when no problem could be identified or the page could not be read; partial data is still returned. Gated behind `REBIND_ALLOW_DELEGATION=1` (spends OpenRouter credits). See the pipeline below. |
|
|
60
60
|
| `run_lua` | Run a Luau script on the connected machine and return its `return` value as JSON — the fast path for any deterministic sequence (launch apps, place windows, shell commands, pixel search). |
|
|
61
61
|
| `lua_docs` | Full Luau SDK type definitions for `run_lua`. |
|
|
62
|
-
| `list_displays` / `list_windows` / `focus_window` / `active_window` | Display and window enumeration and
|
|
62
|
+
| `list_displays` / `list_windows` / `focus_window` / `position_window` / `active_window` | Display and window enumeration, focus, and placement. |
|
|
63
63
|
| `get_cursor` / `wait` / `calibrate` | Cursor readback, pauses, and the mouse scale probe. |
|
|
64
|
+
| `delegate_task` / `rebind_versus` | Server-side reasoning helpers with no desktop access: one bounded text task, or parallel critics that attack a plan. Registered only with `REBIND_ALLOW_DELEGATION=1` (spends OpenRouter credits). |
|
|
64
65
|
|
|
65
66
|
## Configuration
|
|
66
67
|
|
|
67
68
|
| Env var | Default | Purpose |
|
|
68
69
|
| --- | --- | --- |
|
|
69
70
|
| `REBIND_URL` | `ws://127.0.0.1:19561` | Relay WebSocket URL. |
|
|
70
|
-
| `REBIND_TOKEN` | — | Required when the relay has an auth token set (the **Auth token**
|
|
71
|
+
| `REBIND_TOKEN` | — | Required when the relay has an auth token set (the **Auth token** field in Remote Control's Options). |
|
|
71
72
|
| `REBIND_AGENT_LOG` | off | Path to append one JSONL line per tool call (`{ts, tool, ok, latency_ms, note}`). No screenshot bytes, no user content. |
|
|
73
|
+
| `REBIND_MODEL` | `google/gemini-3.5-flash-lite` | OpenRouter slug for `delegate_task`/`rebind_versus`/`meditate` reasoning (only used with `REBIND_ALLOW_DELEGATION=1`). |
|
|
74
|
+
| `REBIND_VISION_MODEL` | falls back to `REBIND_MODEL` | Multimodal slug for `meditate`'s live-page/captcha vision pass. Set this when `REBIND_MODEL` is a text-only model. |
|
|
75
|
+
| `REBIND_ALLOW_DELEGATION` | off | Set to `1` to register the credit-spending tools: `delegate_task`, `rebind_versus`, `meditate`. |
|
|
72
76
|
|
|
73
77
|
## How it works
|
|
74
78
|
|
|
@@ -80,10 +84,22 @@ Connects, takes a capture, and reports the display — the fastest way to catch
|
|
|
80
84
|
|
|
81
85
|
**One coordinate space.** All results are reported in the pixel space of the last screenshot — the same space the model emits — so no rescaling math ever falls on the model.
|
|
82
86
|
|
|
87
|
+
**Machine-aware orientation.** At startup the server probes the connected machine through the relay (OS, shortcut modifier, available shell tools, installed apps) and serves it as the MCP `instructions` block, along with a policy: prefer `run_lua` for deterministic work, keyboard over mouse, and screenshots last. The full Luau SDK type definitions are embedded inline so the model never has to guess `run_lua` APIs (including the coroutine-safe HID path — `HID.Down`/`Up`/`MoveTo`/`Combo` work in a one-shot chunk, `HID.Press` does not). The OS is read from the relay, not `process.platform`, so the guidance is correct even when the server and the relay run on different machines.
|
|
88
|
+
|
|
89
|
+
## Solve pipeline
|
|
90
|
+
|
|
91
|
+
`meditate` gathers; it does not submit. The intended flow, all gated behind `REBIND_ALLOW_DELEGATION=1`:
|
|
92
|
+
|
|
93
|
+
1. **`meditate`** → a dossier. Evidence is gathered by **fetching, not by driving the GUI**: a direct GET returns the server's own bytes, and when that comes back a JS shell the script bundles are pulled in so the endpoints the page calls are visible to the model, which then requests them by id. Same-origin URLs are fetched **from inside the page** — the browser's own session, cookies and TLS answer them, so an endpoint that refuses a bare curl responds normally; cross-origin falls back to relay-side curl. The rendered DOM is read when a fetch cannot see the content. It then leaves the browser **open on the problem page** and returns `form` (per-field `{value, source, op}`, traced to evidence) and `submittable`. It never bakes in the captcha — that can rotate, so it is read live at submit time, and its absence does not make a dossier unsubmittable.
|
|
94
|
+
2. **`rebind_versus`** → feed it the dossier to attack the reconciled values before anything irreversible happens.
|
|
95
|
+
3. **Solve (caller-driven, by sight)** on the still-open page: re-navigate to the anchor and confirm the URL; screenshot → click field → type each `form` value; tick attachment checkboxes; read the **live** captcha off the page; then **re-extract the filled form and diff it against the dossier** — on any mismatch or unresolved conflict, stop (do not submit). Only on a clean diff, click submit and verify. When `submittable` is false there is no form to fill; surface the answer instead.
|
|
96
|
+
|
|
97
|
+
The verify-diff before an irreversible submit is a discipline the caller enforces — auto-submitting reconciled data fails closed, not open.
|
|
98
|
+
|
|
83
99
|
## Develop
|
|
84
100
|
|
|
85
101
|
```sh
|
|
86
|
-
bun
|
|
102
|
+
bun run --cwd packages/mcp-server test # src/ and tests/
|
|
87
103
|
bun run --cwd packages/mcp-server typecheck
|
|
88
104
|
```
|
|
89
105
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebind.gg/mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server exposing Rebind as computer-use tools: screenshot, verified click, type, key, scroll, and window control over the Remote
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server exposing Rebind as computer-use tools: screenshot, verified click, type, key, scroll, and window control over the Remote Control protocol.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"rebind-mcp-server": "src/index.ts"
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
"mcp",
|
|
31
31
|
"model-context-protocol",
|
|
32
32
|
"computer-use",
|
|
33
|
-
"openclaw",
|
|
34
33
|
"hid",
|
|
35
34
|
"automation",
|
|
36
35
|
"keyboard",
|
|
@@ -42,12 +41,16 @@
|
|
|
42
41
|
"scripts": {
|
|
43
42
|
"start": "bun run src/index.ts",
|
|
44
43
|
"selftest": "bun run src/index.ts --selftest",
|
|
45
|
-
"test": "bun test tests/",
|
|
46
|
-
"typecheck": "tsc --noEmit"
|
|
44
|
+
"test": "bun test src/ tests/",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"bench": "bun run bench/runner.ts",
|
|
47
|
+
"bench:selftest": "bun run bench/runner.ts --selftest",
|
|
48
|
+
"bench:setup": "bun run bench/runner.ts --setup-only",
|
|
49
|
+
"bench:demo": "bash bench/demo.sh"
|
|
47
50
|
},
|
|
48
51
|
"dependencies": {
|
|
49
52
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
50
|
-
"@rebind.gg/client-ts": "0.
|
|
53
|
+
"@rebind.gg/client-ts": "0.3.0",
|
|
51
54
|
"sharp": "^0.34.2",
|
|
52
55
|
"zod": "^3.25.0"
|
|
53
56
|
},
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const clone = document.documentElement.cloneNode(true);
|
|
3
|
+
clone
|
|
4
|
+
.querySelectorAll('script, style, link[rel="stylesheet"], svg, noscript')
|
|
5
|
+
.forEach((el) => {
|
|
6
|
+
el.remove();
|
|
7
|
+
});
|
|
8
|
+
clone.querySelectorAll("*").forEach((el) => {
|
|
9
|
+
[...el.attributes].forEach((attr) => {
|
|
10
|
+
if (attr.name.startsWith("on") || attr.name === "style")
|
|
11
|
+
el.removeAttribute(attr.name);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
clone.querySelectorAll("[href]").forEach((el) => {
|
|
15
|
+
try {
|
|
16
|
+
el.setAttribute(
|
|
17
|
+
"href",
|
|
18
|
+
new URL(el.getAttribute("href"), document.baseURI).href,
|
|
19
|
+
);
|
|
20
|
+
} catch (e) {}
|
|
21
|
+
});
|
|
22
|
+
clone.querySelectorAll("[src]").forEach((el) => {
|
|
23
|
+
try {
|
|
24
|
+
el.setAttribute(
|
|
25
|
+
"src",
|
|
26
|
+
new URL(el.getAttribute("src"), document.baseURI).href,
|
|
27
|
+
);
|
|
28
|
+
} catch (e) {}
|
|
29
|
+
});
|
|
30
|
+
clone.querySelectorAll("[action]").forEach((el) => {
|
|
31
|
+
try {
|
|
32
|
+
el.setAttribute(
|
|
33
|
+
"action",
|
|
34
|
+
new URL(el.getAttribute("action"), document.baseURI).href,
|
|
35
|
+
);
|
|
36
|
+
} catch (e) {}
|
|
37
|
+
});
|
|
38
|
+
const cleanedHTML = clone.outerHTML
|
|
39
|
+
.replace(/\s{2,}/g, " ")
|
|
40
|
+
.replace(/>\s+</g, ">\n<");
|
|
41
|
+
const forms = document.querySelectorAll("form");
|
|
42
|
+
let formOut = "";
|
|
43
|
+
const formElementsSet = new Set();
|
|
44
|
+
forms.forEach((form, fi) => {
|
|
45
|
+
const rect = form.getBoundingClientRect();
|
|
46
|
+
const scrollY = Math.round(rect.top + window.scrollY);
|
|
47
|
+
formOut += `[Form ${fi}] action="${form.action || ""}" method="${form.method}" scrollY=${scrollY}\n`;
|
|
48
|
+
form
|
|
49
|
+
.querySelectorAll("input, select, textarea, button")
|
|
50
|
+
.forEach((el, ei) => {
|
|
51
|
+
formElementsSet.add(el);
|
|
52
|
+
const r = el.getBoundingClientRect();
|
|
53
|
+
const sY = Math.round(r.top + window.scrollY);
|
|
54
|
+
const label = (
|
|
55
|
+
el.getAttribute("aria-label") ||
|
|
56
|
+
el.value ||
|
|
57
|
+
el.textContent ||
|
|
58
|
+
""
|
|
59
|
+
)
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(/\s+/g, " ")
|
|
62
|
+
.slice(0, 60);
|
|
63
|
+
formOut += ` [${fi}.${ei}] <${el.tagName.toLowerCase()}> type="${el.type || ""}" name="${el.name || ""}" scrollY=${sY} label="${label}"\n`;
|
|
64
|
+
});
|
|
65
|
+
formOut += "\n";
|
|
66
|
+
});
|
|
67
|
+
let otherOut = "";
|
|
68
|
+
let idx = 0;
|
|
69
|
+
document
|
|
70
|
+
.querySelectorAll("a, button, input, select, textarea, [role]")
|
|
71
|
+
.forEach((el) => {
|
|
72
|
+
if (formElementsSet.has(el) || el.closest("form")) return;
|
|
73
|
+
const rect = el.getBoundingClientRect();
|
|
74
|
+
const scrollY = Math.round(rect.top + window.scrollY);
|
|
75
|
+
const label = (
|
|
76
|
+
el.getAttribute("aria-label") ||
|
|
77
|
+
el.textContent ||
|
|
78
|
+
el.value ||
|
|
79
|
+
""
|
|
80
|
+
)
|
|
81
|
+
.trim()
|
|
82
|
+
.replace(/\s+/g, " ")
|
|
83
|
+
.slice(0, 60);
|
|
84
|
+
const href = el.href ? el.href : "";
|
|
85
|
+
const role = el.getAttribute("role") || "";
|
|
86
|
+
const tag = el.tagName.toLowerCase();
|
|
87
|
+
const ac = el.getAttribute("aria-controls") || "";
|
|
88
|
+
const ae = el.getAttribute("aria-expanded");
|
|
89
|
+
const asel = el.getAttribute("aria-selected");
|
|
90
|
+
const submitish =
|
|
91
|
+
el.type === "submit" ||
|
|
92
|
+
el.type === "reset" ||
|
|
93
|
+
el.name === "intent" ||
|
|
94
|
+
/\b(submit|save|reset|cancel|delete)\b/i.test(label);
|
|
95
|
+
const reveal =
|
|
96
|
+
!href &&
|
|
97
|
+
!submitish &&
|
|
98
|
+
(role === "tab" ||
|
|
99
|
+
!!ac ||
|
|
100
|
+
ae !== null ||
|
|
101
|
+
asel !== null ||
|
|
102
|
+
tag === "button" ||
|
|
103
|
+
role === "button");
|
|
104
|
+
otherOut += `[${idx}] <${tag}> role="${role}" scrollY=${scrollY} name="${label}"${href ? ` href="${href}"` : ""}${ac ? ` aria-controls="${ac}"` : ""}${ae !== null ? ` aria-expanded="${ae}"` : ""}${asel !== null ? ` aria-selected="${asel}"` : ""}${reveal ? " reveal=1" : ""}\n`;
|
|
105
|
+
idx++;
|
|
106
|
+
});
|
|
107
|
+
const walker = document.createTreeWalker(
|
|
108
|
+
document.body,
|
|
109
|
+
NodeFilter.SHOW_TEXT,
|
|
110
|
+
null,
|
|
111
|
+
false,
|
|
112
|
+
);
|
|
113
|
+
const textParts = [];
|
|
114
|
+
let node = walker.nextNode();
|
|
115
|
+
while (node) {
|
|
116
|
+
const parent = node.parentElement;
|
|
117
|
+
if (!parent || !["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)) {
|
|
118
|
+
const t = node.nodeValue.replace(/\s+/g, " ").trim();
|
|
119
|
+
if (t) textParts.push(t);
|
|
120
|
+
}
|
|
121
|
+
node = walker.nextNode();
|
|
122
|
+
}
|
|
123
|
+
const plainText = textParts
|
|
124
|
+
.join(" ")
|
|
125
|
+
.replace(/\s{2,}/g, " ")
|
|
126
|
+
.trim();
|
|
127
|
+
let pageURL = "";
|
|
128
|
+
try {
|
|
129
|
+
pageURL = window.top.location.href;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
pageURL = location.href;
|
|
132
|
+
}
|
|
133
|
+
pageURL = pageURL.split("#RB:")[0];
|
|
134
|
+
const combined =
|
|
135
|
+
"=== Page URL ===\n" +
|
|
136
|
+
pageURL +
|
|
137
|
+
"\n\n=== Forms & Form Elements ===\n" +
|
|
138
|
+
formOut +
|
|
139
|
+
"\n=== Other Interactive Elements ===\n" +
|
|
140
|
+
otherOut +
|
|
141
|
+
"\n=== Plain Text (including hidden) ===\n" +
|
|
142
|
+
plainText +
|
|
143
|
+
"\n\n=== Cleaned HTML (absolute links) ===\n" +
|
|
144
|
+
cleanedHTML;
|
|
145
|
+
function fallbackCopy(text) {
|
|
146
|
+
const ta = document.createElement("textarea");
|
|
147
|
+
ta.value = text;
|
|
148
|
+
ta.style.position = "fixed";
|
|
149
|
+
ta.style.left = "-9999px";
|
|
150
|
+
document.body.appendChild(ta);
|
|
151
|
+
ta.select();
|
|
152
|
+
try {
|
|
153
|
+
document.execCommand("copy");
|
|
154
|
+
} catch (err) {}
|
|
155
|
+
document.body.removeChild(ta);
|
|
156
|
+
} /* Delivery. The clipboard CANNOT be relied on here: after the omnibox runs this,
|
|
157
|
+
focus usually stays in the address bar, so navigator.clipboard.writeText
|
|
158
|
+
rejects with NotAllowedError ("Document is not focused") roughly half the time
|
|
159
|
+
and the dump is silently lost. A page cannot focus itself, so there is no fix
|
|
160
|
+
from in here. location.hash needs no focus and no permission, and the caller
|
|
161
|
+
reads it straight off the tab URL - that is the reliable channel. The
|
|
162
|
+
clipboard write stays as a best-effort bonus (documented behavior) and its
|
|
163
|
+
failure no longer matters. The hash is cleared afterwards so the address bar
|
|
164
|
+
is not left holding the whole dump. */
|
|
165
|
+
try {
|
|
166
|
+
location.hash = `RB:${encodeURIComponent(combined)}`;
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
try {
|
|
169
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
170
|
+
} catch (e) {}
|
|
171
|
+
}, 20000);
|
|
172
|
+
} catch (e) {}
|
|
173
|
+
try {
|
|
174
|
+
if (navigator.clipboard?.writeText) {
|
|
175
|
+
navigator.clipboard.writeText(combined).catch(() => {
|
|
176
|
+
fallbackCopy(combined);
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
fallbackCopy(combined);
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {}
|
|
182
|
+
})();
|