@rebind.gg/mcp-server 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rebind
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @rebind.gg/mcp-server
2
+
3
+ Stdio MCP server that exposes Rebind as computer-use tools: `screenshot`,
4
+ `screenshot_window`, `zoom`, `click` (verified), `move_mouse`, `type`, `key`,
5
+ `scroll`, `run_lua`, `wait`, window control, and `calibrate`. Wraps
6
+ `@rebind.gg/client-ts`; requires a remote-control
7
+ server running in Rebind — `remote.luau` (recommended) or `remote_access.lua`
8
+ (protocol >= 1.1.0). The `click` tool clicks via `hid.press`, which is
9
+ coroutine-only; use `remote.luau` or a current `remote_access.lua` (older copies
10
+ called it outside a coroutine, so clicks silently failed).
11
+
12
+ ## Use
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "rebind": {
18
+ "command": "bunx",
19
+ "args": ["@rebind.gg/mcp-server"],
20
+ "env": { "REBIND_URL": "ws://127.0.0.1:19561" }
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ `REBIND_TOKEN` is required when the server has an auth token set (the **Auth
27
+ token** panel field on `remote.luau`, or `AUTH_TOKEN` on `remote_access.lua`).
28
+
29
+ Check the relay is reachable before wiring it up:
30
+
31
+ ```sh
32
+ bunx @rebind.gg/mcp-server --selftest
33
+ ```
34
+
35
+ Set `REBIND_AGENT_LOG=<path>` to append one JSONL line per tool call
36
+ (`{ts, tool, ok, latency_ms, note}`) — no screenshot bytes, no user content.
37
+ Off unless set.
38
+
39
+ `click` and `move_mouse` reach an absolute screen point by reading the real OS
40
+ cursor and sending relative HID deltas (which map 1:1 to pixels), correcting
41
+ against the true cursor until it lands. This sidesteps the relay's own absolute
42
+ move, whose delta is computed from a cached cursor position that goes stale and
43
+ flings the pointer to a screen edge. A mouse scale probe still runs at startup;
44
+ if pointer ballistics are detected (enhance pointer precision, DPI mismatch) the
45
+ probe reports it, since acceleration would break the 1:1 assumption.
46
+
47
+ ## Develop
48
+
49
+ ```sh
50
+ bun test packages/mcp-server/tests/
51
+ bun run --cwd packages/mcp-server typecheck
52
+ ```
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@rebind.gg/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server exposing Rebind as computer-use tools: screenshot, verified click, type, key, scroll, and window control over the Remote Access protocol.",
5
+ "type": "module",
6
+ "bin": {
7
+ "rebind-mcp-server": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "license": "MIT",
15
+ "author": {
16
+ "name": "US Input Company",
17
+ "email": "inquiries@usinput.com",
18
+ "url": "https://usinput.com"
19
+ },
20
+ "homepage": "https://rebind.gg",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/usinput/rebind-client-ts",
24
+ "directory": "packages/mcp-server"
25
+ },
26
+ "bugs": {
27
+ "email": "support@rebind.gg"
28
+ },
29
+ "keywords": [
30
+ "rebind",
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "computer-use",
34
+ "openclaw",
35
+ "hid",
36
+ "automation",
37
+ "keyboard",
38
+ "mouse"
39
+ ],
40
+ "engines": {
41
+ "bun": ">=1.1"
42
+ },
43
+ "scripts": {
44
+ "start": "bun run src/index.ts",
45
+ "selftest": "bun run src/index.ts --selftest",
46
+ "test": "bun test tests/",
47
+ "typecheck": "tsc --noEmit"
48
+ },
49
+ "dependencies": {
50
+ "@modelcontextprotocol/sdk": "^1.12.0",
51
+ "@rebind.gg/client-ts": "0.2.0",
52
+ "sharp": "^0.34.2",
53
+ "zod": "^3.25.0"
54
+ },
55
+ "devDependencies": {
56
+ "typescript": "^5.4.0"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ }
61
+ }
package/src/coords.ts ADDED
@@ -0,0 +1,37 @@
1
+ // pure image-space -> screen-space mapping for the last screenshot taken.
2
+ // the model only ever sees and emits coordinates in the pixel space of the
3
+ // screenshot in front of it; all mapping back to signed virtual-desktop
4
+ // screen coordinates lives here.
5
+
6
+ export interface ShotMapping {
7
+ /** 1-based index of the captured display, for region re-captures (zoom). */
8
+ displayIndex: number;
9
+ /** signed virtual-desktop origin of the captured display. */
10
+ originX: number;
11
+ originY: number;
12
+ /** image pixels per screen point (capture scale x resize factor). */
13
+ scale: number;
14
+ /** dims of the image the model saw, for bounds checking. */
15
+ imageWidth: number;
16
+ imageHeight: number;
17
+ }
18
+
19
+ export interface Point {
20
+ x: number;
21
+ y: number;
22
+ }
23
+
24
+ export function mapToScreen(shot: ShotMapping, ix: number, iy: number): Point {
25
+ return {
26
+ x: Math.round(shot.originX + ix / shot.scale),
27
+ y: Math.round(shot.originY + iy / shot.scale),
28
+ };
29
+ }
30
+
31
+ export function inImageBounds(
32
+ shot: ShotMapping,
33
+ ix: number,
34
+ iy: number,
35
+ ): boolean {
36
+ return ix >= 0 && ix < shot.imageWidth && iy >= 0 && iy < shot.imageHeight;
37
+ }
package/src/image.ts ADDED
@@ -0,0 +1,85 @@
1
+ // the model-facing image pipeline: decode the native-resolution PNG from
2
+ // Screen.Capture, downscale to the vision sweet spot, draw the cursor
3
+ // crosshair in image space, and encode webp for the MCP image block.
4
+
5
+ import sharp from "sharp";
6
+
7
+ /** long-edge target. XGA-ish is the vision-accuracy sweet spot and keeps the
8
+ * payload small; raise only when small text must be read (use zoom instead). */
9
+ export const DEFAULT_MAX_EDGE = 1280;
10
+
11
+ /** odd so the center lands on a pixel. */
12
+ const CROSSHAIR_SIZE = 25;
13
+
14
+ export interface RenderedShot {
15
+ /** webp bytes for the MCP image content block. */
16
+ data: Buffer;
17
+ width: number;
18
+ height: number;
19
+ /** image pixels per screen point of the captured area. */
20
+ scale: number;
21
+ }
22
+
23
+ function crosshairSvg(): Buffer {
24
+ const s = CROSSHAIR_SIZE;
25
+ const c = (s - 1) / 2;
26
+ return Buffer.from(
27
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}">` +
28
+ `<line x1="${c}" y1="0" x2="${c}" y2="${s}" stroke="white" stroke-width="5"/>` +
29
+ `<line x1="0" y1="${c}" x2="${s}" y2="${c}" stroke="white" stroke-width="5"/>` +
30
+ `<line x1="${c}" y1="0" x2="${c}" y2="${s}" stroke="red" stroke-width="2"/>` +
31
+ `<line x1="0" y1="${c}" x2="${s}" y2="${c}" stroke="red" stroke-width="2"/>` +
32
+ `</svg>`,
33
+ );
34
+ }
35
+
36
+ const clamp = (v: number, lo: number, hi: number) =>
37
+ Math.min(Math.max(v, lo), Math.max(lo, hi));
38
+
39
+ export async function renderScreenshot(opts: {
40
+ /** native-resolution lossless PNG from Screen.Capture. */
41
+ png: Buffer;
42
+ /** logical size of the captured area in screen points. */
43
+ logicalWidth: number;
44
+ logicalHeight: number;
45
+ /** cursor relative to the captured area, in screen points; omit to skip the marker. */
46
+ cursor?: { x: number; y: number };
47
+ maxEdge?: number;
48
+ }): Promise<RenderedShot> {
49
+ const maxEdge = opts.maxEdge ?? DEFAULT_MAX_EDGE;
50
+ const meta = await sharp(opts.png).metadata();
51
+ const nativeW = meta.width;
52
+ const nativeH = meta.height;
53
+ if (!nativeW || !nativeH) throw new Error("could not decode capture PNG");
54
+
55
+ let outW = nativeW;
56
+ let outH = nativeH;
57
+ const long = Math.max(nativeW, nativeH);
58
+ let img = sharp(opts.png);
59
+ if (long > maxEdge) {
60
+ const f = maxEdge / long;
61
+ outW = Math.max(1, Math.round(nativeW * f));
62
+ outH = Math.max(1, Math.round(nativeH * f));
63
+ img = img.resize(outW, outH);
64
+ }
65
+ const scale = outW / opts.logicalWidth;
66
+
67
+ if (opts.cursor) {
68
+ const px = Math.round(opts.cursor.x * scale);
69
+ const py = Math.round(opts.cursor.y * scale);
70
+ // skip the marker when the cursor sits outside the captured area
71
+ if (px >= 0 && px < outW && py >= 0 && py < outH) {
72
+ const half = (CROSSHAIR_SIZE - 1) / 2;
73
+ img = img.composite([
74
+ {
75
+ input: crosshairSvg(),
76
+ left: clamp(px - half, 0, outW - CROSSHAIR_SIZE),
77
+ top: clamp(py - half, 0, outH - CROSSHAIR_SIZE),
78
+ },
79
+ ]);
80
+ }
81
+ }
82
+
83
+ const data = await img.webp({ quality: 80 }).toBuffer();
84
+ return { data, width: outW, height: outH, scale };
85
+ }
package/src/index.ts ADDED
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env bun
2
+
3
+ // stdio MCP server wrapping @rebind.gg/client-ts. all model-facing features
4
+ // live here: image pipeline, coordinate bookkeeping, verified click,
5
+ // calibration. the Rust/Lua layers below ship primitives only.
6
+ //
7
+ // config: REBIND_URL (default ws://127.0.0.1:19561), REBIND_TOKEN (optional).
8
+
9
+ import { appendFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { RebindRemote } from "@rebind.gg/client-ts";
14
+ import { z } from "zod";
15
+ import { renderScreenshot } from "./image.ts";
16
+ import { Session } from "./session.ts";
17
+
18
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
19
+
20
+ function text(t: string) {
21
+ return { content: [{ type: "text" as const, text: t }] };
22
+ }
23
+
24
+ async function main() {
25
+ const url = process.env.REBIND_URL ?? "ws://127.0.0.1:19561";
26
+ const client = new RebindRemote(url, {
27
+ token: process.env.REBIND_TOKEN ?? "",
28
+ timeoutMs: 15000,
29
+ });
30
+
31
+ // --selftest: verify the relay is reachable and capturing, then exit. Used by
32
+ // the installer and by users to diagnose the #1 first-run failure (relay not
33
+ // loaded) without wiring up the MCP transport.
34
+ if (process.argv.includes("--selftest")) {
35
+ try {
36
+ await client.connect();
37
+ const cap = await client.screenCapture({});
38
+ console.error(
39
+ `selftest OK: relay ${url}, display ${cap.display.index} ${cap.display.width}x${cap.display.height}`,
40
+ );
41
+ process.exit(0);
42
+ } catch (e) {
43
+ console.error(
44
+ `selftest FAILED: Rebind relay not reachable on ${url}.\n` +
45
+ `Start Rebind and load the Remote Access script (remote_access.lua).\n(${e})`,
46
+ );
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ await client.connect();
52
+
53
+ const session = new Session(client);
54
+ try {
55
+ await session.calibrate();
56
+ } catch (e) {
57
+ session.calibration = { calibrated: false, reason: String(e) };
58
+ }
59
+
60
+ const server = new McpServer({ name: "rebind", version: "0.1.0" });
61
+
62
+ // REBIND_AGENT_LOG=<path>: opt-in telemetry — one JSONL line per tool call
63
+ // ({ts, tool, ok, latency_ms, note}). No screenshot bytes, no user content.
64
+ // No-op when unset.
65
+ const telemetryPath = process.env.REBIND_AGENT_LOG?.replace(/^~(?=\/)/, homedir());
66
+ const logCall = (tool: string, ok: boolean, t0: number, note?: string) => {
67
+ if (!telemetryPath) return;
68
+ try {
69
+ appendFileSync(
70
+ telemetryPath,
71
+ `${JSON.stringify({ ts: new Date().toISOString(), tool, ok, latency_ms: Math.round(performance.now() - t0), ...(note ? { note } : {}) })}\n`,
72
+ );
73
+ } catch {
74
+ // telemetry must never break a tool call
75
+ }
76
+ };
77
+ const register = (name: string, config: object, handler: (args: any) => Promise<any>) => {
78
+ server.registerTool(name, config as any, async (args: any) => {
79
+ const t0 = performance.now();
80
+ try {
81
+ const result = await handler(args);
82
+ logCall(name, true, t0);
83
+ return result;
84
+ } catch (e) {
85
+ logCall(name, false, t0, String(e));
86
+ throw e;
87
+ }
88
+ });
89
+ };
90
+
91
+ register(
92
+ "screenshot",
93
+ {
94
+ description:
95
+ "Capture a display and return it as an image. The cursor is marked with a red crosshair. " +
96
+ "All click/move coordinates you emit must be pixel coordinates in the MOST RECENT screenshot. " +
97
+ "After every action, take a new screenshot and verify the action had the intended effect before continuing; if not, correct and retry.",
98
+ inputSchema: {
99
+ display: z
100
+ .number()
101
+ .int()
102
+ .min(1)
103
+ .optional()
104
+ .describe(
105
+ "1-based display index from list_displays; defaults to the display under the cursor",
106
+ ),
107
+ },
108
+ },
109
+ async ({ display }) => {
110
+ const cap = await client.screenCapture({ display });
111
+ const shot = await renderScreenshot({
112
+ png: Buffer.from(cap.image, "base64"),
113
+ logicalWidth: cap.display.width,
114
+ logicalHeight: cap.display.height,
115
+ cursor: {
116
+ x: cap.cursor.x - cap.display.x,
117
+ y: cap.cursor.y - cap.display.y,
118
+ },
119
+ });
120
+ session.lastShot = {
121
+ displayIndex: cap.display.index,
122
+ originX: cap.display.x,
123
+ originY: cap.display.y,
124
+ scale: shot.scale,
125
+ imageWidth: shot.width,
126
+ imageHeight: shot.height,
127
+ };
128
+ const win = await client.systemWindow().catch(() => null);
129
+ const lines = [
130
+ `display ${cap.display.index}: ${cap.display.width}x${cap.display.height} at (${cap.display.x}, ${cap.display.y})${cap.display.primary ? " (primary)" : ""}`,
131
+ `image: ${shot.width}x${shot.height} — emit click/move coordinates in this image's pixel space`,
132
+ `cursor: image (${Math.round((cap.cursor.x - cap.display.x) * shot.scale)}, ${Math.round((cap.cursor.y - cap.display.y) * shot.scale)}), marked with a red crosshair`,
133
+ win ? `active window: "${win.title}" (${win.process})` : null,
134
+ session.calibration && !session.calibration.calibrated
135
+ ? `WARNING: mouse not calibrated — ${session.calibration.reason}. Clicks may land off-target; verify each one.`
136
+ : null,
137
+ ].filter(Boolean);
138
+ return {
139
+ content: [
140
+ { type: "text" as const, text: lines.join("\n") },
141
+ {
142
+ type: "image" as const,
143
+ data: shot.data.toString("base64"),
144
+ mimeType: "image/webp",
145
+ },
146
+ ],
147
+ };
148
+ },
149
+ );
150
+
151
+ register(
152
+ "screenshot_window",
153
+ {
154
+ description:
155
+ "Capture just ONE window (by title substring, or the active window if omitted) instead of a whole display — smaller, cheaper, and monitor-proof. " +
156
+ "Click/move coordinates you emit map into THIS capture exactly like screenshot. Prefer this over a full-screen screenshot to read or verify a specific app.",
157
+ inputSchema: {
158
+ title: z
159
+ .string()
160
+ .optional()
161
+ .describe("window title substring; omit to capture the active window"),
162
+ },
163
+ },
164
+ async ({ title }) => {
165
+ const win = title
166
+ ? (await client.windowList(title))[0]
167
+ : await client.systemWindow();
168
+ if (!win) throw new Error(`no window matches "${title}"`);
169
+ const cap = await client.screenCaptureWindow({ title: win.title });
170
+ // remote.luau captures the window rect clamped to its display; recover the
171
+ // capture's screen-space origin so image coords map back to the screen.
172
+ const originX = Math.max(cap.display.x, win.x);
173
+ const originY = Math.max(cap.display.y, win.y);
174
+ const rx = Math.max(0, win.x - cap.display.x);
175
+ const ry = Math.max(0, win.y - cap.display.y);
176
+ const logicalWidth = Math.min(win.width, cap.display.width - rx);
177
+ const logicalHeight = Math.min(win.height, cap.display.height - ry);
178
+ const curInside =
179
+ cap.cursor.x >= originX &&
180
+ cap.cursor.x < originX + logicalWidth &&
181
+ cap.cursor.y >= originY &&
182
+ cap.cursor.y < originY + logicalHeight;
183
+ const shot = await renderScreenshot({
184
+ png: Buffer.from(cap.image, "base64"),
185
+ logicalWidth,
186
+ logicalHeight,
187
+ cursor: curInside
188
+ ? { x: cap.cursor.x - originX, y: cap.cursor.y - originY }
189
+ : undefined,
190
+ });
191
+ session.lastShot = {
192
+ displayIndex: cap.display.index,
193
+ originX,
194
+ originY,
195
+ scale: shot.scale,
196
+ imageWidth: shot.width,
197
+ imageHeight: shot.height,
198
+ };
199
+ return {
200
+ content: [
201
+ {
202
+ type: "text" as const,
203
+ text: `window "${win.title}" at screen (${win.x}, ${win.y}), ${logicalWidth}x${logicalHeight} — emit click/move coords in this image's pixel space (${shot.width}x${shot.height})`,
204
+ },
205
+ {
206
+ type: "image" as const,
207
+ data: shot.data.toString("base64"),
208
+ mimeType: "image/webp",
209
+ },
210
+ ],
211
+ };
212
+ },
213
+ );
214
+
215
+ register(
216
+ "zoom",
217
+ {
218
+ description:
219
+ "Capture a region of the last screenshot at full native resolution, for reading small text or inspecting small targets. " +
220
+ "READ-ONLY: click coordinates still refer to the last full screenshot, not the zoomed image.",
221
+ inputSchema: {
222
+ x: z
223
+ .number()
224
+ .describe("region top-left x, in last-screenshot image pixels"),
225
+ y: z
226
+ .number()
227
+ .describe("region top-left y, in last-screenshot image pixels"),
228
+ w: z
229
+ .number()
230
+ .min(1)
231
+ .describe("region width, in last-screenshot image pixels"),
232
+ h: z
233
+ .number()
234
+ .min(1)
235
+ .describe("region height, in last-screenshot image pixels"),
236
+ },
237
+ },
238
+ async ({ x, y, w, h }) => {
239
+ const shot = session.lastShot;
240
+ if (!shot)
241
+ throw new Error("no screenshot taken yet — call screenshot first");
242
+ const topLeft = session.mapImagePoint(x, y);
243
+ const cap = await client.screenCapture({
244
+ display: shot.displayIndex,
245
+ region: {
246
+ x: topLeft.x - shot.originX,
247
+ y: topLeft.y - shot.originY,
248
+ w: Math.ceil(w / shot.scale),
249
+ h: Math.ceil(h / shot.scale),
250
+ },
251
+ });
252
+ const rendered = await renderScreenshot({
253
+ png: Buffer.from(cap.image, "base64"),
254
+ logicalWidth: Math.ceil(w / shot.scale),
255
+ logicalHeight: Math.ceil(h / shot.scale),
256
+ });
257
+ return {
258
+ content: [
259
+ {
260
+ type: "text" as const,
261
+ text: `zoomed region (${x}, ${y}) ${w}x${h} of the last screenshot. Coordinates for clicks still refer to the last full screenshot.`,
262
+ },
263
+ {
264
+ type: "image" as const,
265
+ data: rendered.data.toString("base64"),
266
+ mimeType: "image/webp",
267
+ },
268
+ ],
269
+ };
270
+ },
271
+ );
272
+
273
+ register(
274
+ "list_displays",
275
+ { description: "Enumerate displays with their virtual-desktop geometry." },
276
+ async () => text(JSON.stringify(await client.screenDisplays(), null, 2)),
277
+ );
278
+
279
+ register(
280
+ "click",
281
+ {
282
+ description:
283
+ "Move the mouse to a point in the last screenshot and click. The landing position is verified and corrected before pressing. " +
284
+ "Prefer keyboard shortcuts (the key tool) for fiddly widgets like address bars and dropdowns.",
285
+ inputSchema: {
286
+ x: z.number().describe("x in last-screenshot image pixels"),
287
+ y: z.number().describe("y in last-screenshot image pixels"),
288
+ button: z.enum(["left", "right", "middle"]).default("left"),
289
+ double: z.boolean().default(false),
290
+ },
291
+ },
292
+ async ({ x, y, button, double }) => {
293
+ const target = session.mapImagePoint(x, y);
294
+ const r = await session.verifiedClick(target.x, target.y, button, double);
295
+ return text(
296
+ r.ok
297
+ ? `clicked ${button}${double ? " (double)" : ""} at screen (${r.landed.x}, ${r.landed.y})${r.corrected ? " after one correction" : ""}`
298
+ : `WARNING: cursor landed at (${r.landed.x}, ${r.landed.y}) instead of (${target.x}, ${target.y}) — the click may have missed. Take a screenshot to verify.`,
299
+ );
300
+ },
301
+ );
302
+
303
+ register(
304
+ "move_mouse",
305
+ {
306
+ description:
307
+ "Move the mouse to a point in the last screenshot without clicking.",
308
+ inputSchema: {
309
+ x: z.number().describe("x in last-screenshot image pixels"),
310
+ y: z.number().describe("y in last-screenshot image pixels"),
311
+ },
312
+ },
313
+ async ({ x, y }) => {
314
+ const target = session.mapImagePoint(x, y);
315
+ const landed = await session.moveTo(target.x, target.y);
316
+ return text(`cursor at screen (${landed.x}, ${landed.y})`);
317
+ },
318
+ );
319
+
320
+ register(
321
+ "type",
322
+ {
323
+ description: "Type text into the focused element via the keyboard.",
324
+ inputSchema: { text: z.string() },
325
+ },
326
+ async ({ text: t }) => {
327
+ client.hidType(t);
328
+ // typing is paced by the relay; give it time proportional to length
329
+ await sleep(Math.min(50 + t.length * 15, 5000));
330
+ return text(`typed ${t.length} characters`);
331
+ },
332
+ );
333
+
334
+ register(
335
+ "key",
336
+ {
337
+ description:
338
+ 'Press a key or combo. Names: "Enter", "Escape", "Tab", "Backspace", arrows ("Up", "Down", "Left", "Right"), letters/digits, modifiers "LCtrl", "LAlt", "LShift", "LMeta". ' +
339
+ 'Combos join with "+", e.g. "LCtrl+L" (focus address bar), "LCtrl+C", "LAlt+Tab".',
340
+ inputSchema: { combo: z.string() },
341
+ },
342
+ async ({ combo }) => {
343
+ client.hidPress(combo, 30);
344
+ await sleep(80);
345
+ return text(`pressed ${combo}`);
346
+ },
347
+ );
348
+
349
+ register(
350
+ "scroll",
351
+ {
352
+ description:
353
+ "Scroll the mouse wheel at the current cursor position. Positive = up, negative = down.",
354
+ inputSchema: { clicks: z.number().int().describe("wheel clicks") },
355
+ },
356
+ async ({ clicks }) => {
357
+ client.hidScroll(clicks);
358
+ await sleep(80);
359
+ return text(`scrolled ${clicks} clicks`);
360
+ },
361
+ );
362
+
363
+ register(
364
+ "list_windows",
365
+ {
366
+ description: "List open windows (handle, title, process, geometry).",
367
+ inputSchema: {
368
+ filter: z.string().optional().describe("title substring filter"),
369
+ },
370
+ },
371
+ async ({ filter }) =>
372
+ text(JSON.stringify(await client.windowList(filter), null, 2)),
373
+ );
374
+
375
+ register(
376
+ "focus_window",
377
+ {
378
+ description:
379
+ "Bring a window to the foreground by handle (from list_windows).",
380
+ inputSchema: { handle: z.number() },
381
+ },
382
+ async ({ handle }) => {
383
+ await client.windowActivate(handle);
384
+ return text(`activated window ${handle}`);
385
+ },
386
+ );
387
+
388
+ register(
389
+ "wait",
390
+ {
391
+ description: "Pause before the next action (page loads, animations).",
392
+ inputSchema: { ms: z.number().int().min(1).max(10000) },
393
+ },
394
+ async ({ ms }) => {
395
+ await sleep(ms);
396
+ return text(`waited ${ms}ms`);
397
+ },
398
+ );
399
+
400
+ register(
401
+ "get_cursor",
402
+ { description: "Read the current cursor position in screen coordinates." },
403
+ async () => text(JSON.stringify(await client.systemMouse())),
404
+ );
405
+
406
+ register(
407
+ "active_window",
408
+ { description: "Read the currently focused window." },
409
+ async () => text(JSON.stringify(await client.systemWindow())),
410
+ );
411
+
412
+ register(
413
+ "calibrate",
414
+ {
415
+ description:
416
+ "Re-run the mouse scale probe (auto-runs at startup). Verifies relative mouse counts map 1:1 to pixels; reports pointer-ballistics misconfiguration.",
417
+ },
418
+ async () => text(JSON.stringify(await session.calibrate())),
419
+ );
420
+
421
+ register(
422
+ "run_lua",
423
+ {
424
+ description:
425
+ "Run a Luau script on the connected machine through Rebind's engine and return its `return` value as JSON. " +
426
+ "PREFER THIS over the screenshot→click loop for any DETERMINISTIC sequence — it is far faster, cheaper, and " +
427
+ "reliable, and it sidesteps multi-monitor guessing. Use it to: launch apps (`System.ExecDetached(\"cmd\", {\"/c\",\"start\",\"\",url})`), " +
428
+ "wait for and place windows on a known display (`local h=Window.Wait(title, ms); Window.Move(h, x, y, w, h)`), " +
429
+ "run shell commands (`System.Exec(cmd) -> {exit,stdout,stderr}`), find UI by color (`Screen.SearchForColor(region,hex)`), " +
430
+ "type, click, read pixels, manage processes. Namespaces: System, HID, Input, Screen, Window, Process, File, Clipboard, Net, Macro, Env, Log. " +
431
+ "Fall back to `screenshot` only for genuinely visual reasoning the script cannot resolve. End the script with `return <value>` to get data back.",
432
+ inputSchema: {
433
+ source: z
434
+ .string()
435
+ .describe("Luau source. End with `return <value>` to return data."),
436
+ },
437
+ },
438
+ async ({ source }) => {
439
+ const result = await client.luaExec(source);
440
+ return text(
441
+ typeof result === "string" ? result : JSON.stringify(result, null, 2),
442
+ );
443
+ },
444
+ );
445
+
446
+ await server.connect(new StdioServerTransport());
447
+ // stdout carries the MCP protocol — log to stderr only
448
+ console.error(
449
+ `rebind-mcp-server: connected to ${url} (calibrated=${session.calibration?.calibrated ?? false})`,
450
+ );
451
+ }
452
+
453
+ main().catch((e) => {
454
+ console.error(`rebind-mcp-server: fatal: ${e}`);
455
+ process.exit(1);
456
+ });
package/src/session.ts ADDED
@@ -0,0 +1,188 @@
1
+ // per-connection state and the two composed behaviors built from existing
2
+ // protocol primitives: verified click (move -> read back -> correct once ->
3
+ // press) and calibration (relative-move scale probe at session start).
4
+
5
+ import type { RebindRemote } from "@rebind.gg/client-ts";
6
+ import {
7
+ inImageBounds,
8
+ mapToScreen,
9
+ type Point,
10
+ type ShotMapping,
11
+ } from "./coords.ts";
12
+
13
+ /** acceptable landing error. relative counts map 1:1 to pixels, but readback
14
+ * (GetCursorPos) lags the just-sent USB move under load, so keep a small margin. */
15
+ export const CLICK_EPS_PX = 5;
16
+
17
+ /** OS cursor + SystemState refresh time after a move burst, before reading back.
18
+ * too short and the readback is stale, the loop mis-corrects, and it diverges. */
19
+ const SETTLE_MS = 70;
20
+
21
+ /** correction passes: read real cursor, step the residual, repeat. */
22
+ const MOVE_MAX_PASSES = 6;
23
+ /** target pixels per glide step — smaller is smoother (more USB frames). */
24
+ const MOVE_STEP_PX = 40;
25
+ /** cap on glide steps in one pass, so a cross-screen move stays bounded. */
26
+ const MOVE_MAX_STEPS = 40;
27
+ /** delay between glide steps within a pass. */
28
+ const MOVE_STEP_MS = 8;
29
+
30
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
31
+ const dist = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
32
+
33
+ export type MouseButton = "left" | "right" | "middle";
34
+
35
+ const BUTTON_CODE: Record<MouseButton, string> = {
36
+ left: "Mouse1",
37
+ right: "Mouse2",
38
+ middle: "Mouse3",
39
+ };
40
+
41
+ export interface ClickResult {
42
+ ok: boolean;
43
+ landed: Point;
44
+ corrected: boolean;
45
+ }
46
+
47
+ export interface CalibrationResult {
48
+ calibrated: boolean;
49
+ /** input counts per screen pixel when the ratio is constant. */
50
+ k?: number;
51
+ reason?: string;
52
+ }
53
+
54
+ export class Session {
55
+ lastShot: ShotMapping | null = null;
56
+ calibration: CalibrationResult | null = null;
57
+
58
+ constructor(readonly client: RebindRemote) {}
59
+
60
+ /** map a point in the last screenshot's image space to screen coords. */
61
+ mapImagePoint(ix: number, iy: number): Point {
62
+ if (!this.lastShot) {
63
+ throw new Error(
64
+ "no screenshot taken yet — call the screenshot tool first",
65
+ );
66
+ }
67
+ if (!inImageBounds(this.lastShot, ix, iy)) {
68
+ throw new Error(
69
+ `(${ix}, ${iy}) is outside the last screenshot (${this.lastShot.imageWidth}x${this.lastShot.imageHeight}) — take a fresh screenshot`,
70
+ );
71
+ }
72
+ return mapToScreen(this.lastShot, ix, iy);
73
+ }
74
+
75
+ /**
76
+ * Move the OS cursor to absolute screen (x, y).
77
+ *
78
+ * The relay's hid.move_smooth / hid.move_to derive their delta from a cached
79
+ * cursor position that goes stale and flings the pointer to a screen edge
80
+ * (observed: target (800,500) landing at (0,0)). Relative counts map 1:1 to
81
+ * pixels, so we read the REAL cursor, glide the residual in small relative
82
+ * steps (human-like, not an instant jump), then read back and correct against
83
+ * the true cursor. Converges to the exact pixel, usually in a single pass.
84
+ */
85
+ async moveTo(x: number, y: number): Promise<Point & { passes: number }> {
86
+ let at = await this.client.systemMouse();
87
+ let passes = 0;
88
+ while (passes < MOVE_MAX_PASSES) {
89
+ const dx = x - at.x;
90
+ const dy = y - at.y;
91
+ const distance = Math.hypot(dx, dy);
92
+ if (distance <= CLICK_EPS_PX) break;
93
+ passes++;
94
+ const steps = Math.min(
95
+ MOVE_MAX_STEPS,
96
+ Math.max(1, Math.round(distance / MOVE_STEP_PX)),
97
+ );
98
+ let sentX = 0;
99
+ let sentY = 0;
100
+ for (let s = 1; s <= steps; s++) {
101
+ const nx = Math.round((dx * s) / steps) - sentX;
102
+ const ny = Math.round((dy * s) / steps) - sentY;
103
+ sentX += nx;
104
+ sentY += ny;
105
+ this.client.hidMove(nx, ny);
106
+ if (s < steps) await sleep(MOVE_STEP_MS);
107
+ }
108
+ await sleep(SETTLE_MS);
109
+ at = await this.client.systemMouse();
110
+ }
111
+ return { x: at.x, y: at.y, passes };
112
+ }
113
+
114
+ /** move (self-correcting), then press. */
115
+ async verifiedClick(
116
+ x: number,
117
+ y: number,
118
+ button: MouseButton = "left",
119
+ double = false,
120
+ ): Promise<ClickResult> {
121
+ const target = { x, y };
122
+ const at = await this.moveTo(x, y);
123
+ const code = BUTTON_CODE[button];
124
+ this.client.hidPress(code, 30);
125
+ if (double) {
126
+ await sleep(80);
127
+ this.client.hidPress(code, 30);
128
+ }
129
+ return {
130
+ ok: dist(at, target) <= CLICK_EPS_PX,
131
+ landed: { x: at.x, y: at.y },
132
+ corrected: at.passes > 1,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * relative-move scale probe. moves to screen center (deterministic anchor
138
+ * away from edge clamp), nudges by increasing counts on each axis, and
139
+ * checks whether counts-per-pixel is constant. a magnitude-dependent ratio
140
+ * means ballistics in the chain (EPP on, DPI mismatch, software fallback) —
141
+ * recorded and surfaced to the agent instead of failing via missed clicks.
142
+ */
143
+ async calibrate(): Promise<CalibrationResult> {
144
+ const done = (r: CalibrationResult): CalibrationResult => {
145
+ this.calibration = r;
146
+ return r;
147
+ };
148
+ // 8 as the smallest probe: large enough to dodge integer quantization,
149
+ // small enough that ballistic acceleration curves still diverge by 256.
150
+ const probes = [8, 64, 256];
151
+ const res = await this.client.screenResolution();
152
+ const saved = await this.client.systemMouse();
153
+ const cx = Math.floor(res.width / 2);
154
+ const cy = Math.floor(res.height / 2);
155
+
156
+ const ratios: number[] = [];
157
+ try {
158
+ for (const axis of ["x", "y"] as const) {
159
+ for (const n of probes) {
160
+ const before = await this.moveTo(cx, cy);
161
+ this.client.hidMove(axis === "x" ? n : 0, axis === "y" ? n : 0);
162
+ await sleep(SETTLE_MS);
163
+ const after = await this.client.systemMouse();
164
+ const moved = axis === "x" ? after.x - before.x : after.y - before.y;
165
+ if (moved <= 0) {
166
+ return done({
167
+ calibrated: false,
168
+ reason: `${axis}-axis probe of ${n} counts produced no movement`,
169
+ });
170
+ }
171
+ ratios.push(n / moved);
172
+ }
173
+ }
174
+ } finally {
175
+ await this.moveTo(saved.x, saved.y);
176
+ }
177
+
178
+ const mean = ratios.reduce((a, b) => a + b, 0) / ratios.length;
179
+ const maxDev = Math.max(...ratios.map((r) => Math.abs(r - mean) / mean));
180
+ if (maxDev > 0.15) {
181
+ return done({
182
+ calibrated: false,
183
+ reason: `counts-per-pixel varies with magnitude (±${Math.round(maxDev * 100)}%) — pointer ballistics in the chain (enhance pointer precision, DPI mismatch, or software fallback)`,
184
+ });
185
+ }
186
+ return done({ calibrated: true, k: mean });
187
+ }
188
+ }