@remcp/runtime 0.2.46 → 0.2.48
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/CHANGELOG.md +19 -0
- package/README.md +42 -25
- package/package.json +6 -2
- package/src/catalog.mjs +1 -1
- package/src/extended/browser.mjs +592 -0
- package/src/extended/catalog.mjs +564 -0
- package/src/extended/common.mjs +209 -0
- package/src/extended/desktop-linux.mjs +1173 -0
- package/src/extended/desktop-macos.mjs +408 -0
- package/src/extended/desktop-windows.mjs +546 -0
- package/src/extended/desktop.mjs +1313 -0
- package/src/extended/diagnostics.mjs +274 -0
- package/src/extended/documents.mjs +381 -0
- package/src/extended/ocr.mjs +139 -0
- package/src/helpers/wayland-eis-helper.py +450 -0
- package/src/index.mjs +46 -44
- package/src/invoke.mjs +3 -3
- package/src/mcp-v2.mjs +104 -0
- package/src/permissions.mjs +1 -1
- package/src/util.mjs +24 -1
- package/src/wayland-remote-desktop.mjs +654 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ReMCP XDG RemoteDesktop + libei sender helper.
|
|
3
|
+
|
|
4
|
+
Owns the portal session and EIS file descriptor in one process so SCM_RIGHTS never
|
|
5
|
+
has to cross Node's net.Socket implementation. JSON-lines are used only between
|
|
6
|
+
this helper and the local ReMCP runtime.
|
|
7
|
+
"""
|
|
8
|
+
import ctypes
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import select
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import gi
|
|
17
|
+
gi.require_version("Gio", "2.0")
|
|
18
|
+
from gi.repository import Gio, GLib
|
|
19
|
+
except Exception as exc:
|
|
20
|
+
print(json.dumps({"ready": False, "kind": "unsupported", "error": f"PyGObject/Gio unavailable: {exc}"}), flush=True)
|
|
21
|
+
sys.exit(78)
|
|
22
|
+
|
|
23
|
+
PORTAL_NAME = "org.freedesktop.portal.Desktop"
|
|
24
|
+
PORTAL_PATH = "/org/freedesktop/portal/desktop"
|
|
25
|
+
REMOTE_IFACE = "org.freedesktop.portal.RemoteDesktop"
|
|
26
|
+
REQUEST_IFACE = "org.freedesktop.portal.Request"
|
|
27
|
+
SESSION_IFACE = "org.freedesktop.portal.Session"
|
|
28
|
+
DEVICE_TYPES = 1 | 2 # keyboard | pointer in the portal API
|
|
29
|
+
|
|
30
|
+
EI_CAP_POINTER = 1 << 0
|
|
31
|
+
EI_CAP_POINTER_ABSOLUTE = 1 << 1
|
|
32
|
+
EI_CAP_KEYBOARD = 1 << 2
|
|
33
|
+
EI_CAP_SCROLL = 1 << 4
|
|
34
|
+
EI_CAP_BUTTON = 1 << 5
|
|
35
|
+
EI_EVENT_CONNECT = 1
|
|
36
|
+
EI_EVENT_DISCONNECT = 2
|
|
37
|
+
EI_EVENT_SEAT_ADDED = 3
|
|
38
|
+
EI_EVENT_SEAT_REMOVED = 4
|
|
39
|
+
EI_EVENT_DEVICE_ADDED = 5
|
|
40
|
+
EI_EVENT_DEVICE_REMOVED = 6
|
|
41
|
+
EI_EVENT_DEVICE_PAUSED = 7
|
|
42
|
+
EI_EVENT_DEVICE_RESUMED = 8
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def emit(payload):
|
|
46
|
+
sys.stdout.write(json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + "\n")
|
|
47
|
+
sys.stdout.flush()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def deep_unwrap(value):
|
|
51
|
+
if isinstance(value, GLib.Variant):
|
|
52
|
+
return deep_unwrap(value.unpack())
|
|
53
|
+
if isinstance(value, dict):
|
|
54
|
+
return {str(k): deep_unwrap(v) for k, v in value.items()}
|
|
55
|
+
if isinstance(value, (list, tuple)):
|
|
56
|
+
return [deep_unwrap(v) for v in value]
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def token(prefix):
|
|
61
|
+
return f"{prefix}_{os.getpid()}_{time.monotonic_ns()}".replace("-", "_")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Portal:
|
|
65
|
+
def __init__(self, timeout_ms):
|
|
66
|
+
self.timeout_ms = timeout_ms
|
|
67
|
+
self.conn = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
68
|
+
self.context = GLib.MainContext.default()
|
|
69
|
+
self.responses = {}
|
|
70
|
+
self.session = None
|
|
71
|
+
self.subscription = self.conn.signal_subscribe(
|
|
72
|
+
PORTAL_NAME,
|
|
73
|
+
REQUEST_IFACE,
|
|
74
|
+
"Response",
|
|
75
|
+
None,
|
|
76
|
+
None,
|
|
77
|
+
Gio.DBusSignalFlags.NONE,
|
|
78
|
+
self._on_response,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _on_response(self, _conn, _sender, object_path, _iface, _signal, params, _data=None):
|
|
82
|
+
try:
|
|
83
|
+
response, results = params.unpack()
|
|
84
|
+
self.responses[object_path] = (int(response), deep_unwrap(results))
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
self.responses[object_path] = (2, {"error": str(exc)})
|
|
87
|
+
|
|
88
|
+
def _wait_response(self, request_path):
|
|
89
|
+
deadline = time.monotonic() + self.timeout_ms / 1000.0
|
|
90
|
+
while time.monotonic() < deadline:
|
|
91
|
+
while self.context.pending():
|
|
92
|
+
self.context.iteration(False)
|
|
93
|
+
if request_path in self.responses:
|
|
94
|
+
response, results = self.responses.pop(request_path)
|
|
95
|
+
if response == 1:
|
|
96
|
+
raise RuntimeError("portal request cancelled")
|
|
97
|
+
if response != 0:
|
|
98
|
+
raise RuntimeError(f"portal request denied (response {response})")
|
|
99
|
+
return results
|
|
100
|
+
self.context.iteration(False)
|
|
101
|
+
time.sleep(0.01)
|
|
102
|
+
raise TimeoutError(f"portal request timed out after {self.timeout_ms} ms")
|
|
103
|
+
|
|
104
|
+
def request(self, method, params):
|
|
105
|
+
reply = self.conn.call_sync(
|
|
106
|
+
PORTAL_NAME,
|
|
107
|
+
PORTAL_PATH,
|
|
108
|
+
REMOTE_IFACE,
|
|
109
|
+
method,
|
|
110
|
+
params,
|
|
111
|
+
GLib.VariantType.new("(o)"),
|
|
112
|
+
Gio.DBusCallFlags.NONE,
|
|
113
|
+
min(self.timeout_ms, 10000),
|
|
114
|
+
None,
|
|
115
|
+
)
|
|
116
|
+
request_path = reply.unpack()[0]
|
|
117
|
+
return self._wait_response(request_path)
|
|
118
|
+
|
|
119
|
+
def open(self, restore_token="", parent_window=""):
|
|
120
|
+
create_opts = {
|
|
121
|
+
"handle_token": GLib.Variant("s", token("create")),
|
|
122
|
+
"session_handle_token": GLib.Variant("s", token("session")),
|
|
123
|
+
}
|
|
124
|
+
created = self.request("CreateSession", GLib.Variant("(a{sv})", (create_opts,)))
|
|
125
|
+
self.session = created.get("session_handle")
|
|
126
|
+
if not isinstance(self.session, str) or not self.session.startswith("/"):
|
|
127
|
+
raise RuntimeError("portal returned no session handle")
|
|
128
|
+
|
|
129
|
+
select_opts = {
|
|
130
|
+
"handle_token": GLib.Variant("s", token("select")),
|
|
131
|
+
"types": GLib.Variant("u", DEVICE_TYPES),
|
|
132
|
+
"persist_mode": GLib.Variant("u", 2),
|
|
133
|
+
}
|
|
134
|
+
if restore_token:
|
|
135
|
+
select_opts["restore_token"] = GLib.Variant("s", restore_token)
|
|
136
|
+
self.request("SelectDevices", GLib.Variant("(oa{sv})", (self.session, select_opts)))
|
|
137
|
+
|
|
138
|
+
start_opts = {"handle_token": GLib.Variant("s", token("start"))}
|
|
139
|
+
started = self.request("Start", GLib.Variant("(osa{sv})", (self.session, parent_window or "", start_opts)))
|
|
140
|
+
devices = int(started.get("devices", 0))
|
|
141
|
+
if devices & DEVICE_TYPES != DEVICE_TYPES:
|
|
142
|
+
raise RuntimeError(f"portal did not grant keyboard and pointer access (devices={devices})")
|
|
143
|
+
return devices, str(started.get("restore_token") or "")
|
|
144
|
+
|
|
145
|
+
def connect_eis(self):
|
|
146
|
+
if not self.session:
|
|
147
|
+
raise RuntimeError("portal session is not open")
|
|
148
|
+
reply, fd_list = self.conn.call_with_unix_fd_list_sync(
|
|
149
|
+
PORTAL_NAME,
|
|
150
|
+
PORTAL_PATH,
|
|
151
|
+
REMOTE_IFACE,
|
|
152
|
+
"ConnectToEIS",
|
|
153
|
+
GLib.Variant("(oa{sv})", (self.session, {})),
|
|
154
|
+
GLib.VariantType.new("(h)"),
|
|
155
|
+
Gio.DBusCallFlags.NONE,
|
|
156
|
+
min(self.timeout_ms, 10000),
|
|
157
|
+
None,
|
|
158
|
+
None,
|
|
159
|
+
)
|
|
160
|
+
handle = int(reply.unpack()[0])
|
|
161
|
+
fd = fd_list.get(handle)
|
|
162
|
+
if fd < 0:
|
|
163
|
+
raise RuntimeError("ConnectToEIS returned an invalid file descriptor")
|
|
164
|
+
return fd
|
|
165
|
+
|
|
166
|
+
def close(self):
|
|
167
|
+
if self.session:
|
|
168
|
+
try:
|
|
169
|
+
self.conn.call_sync(
|
|
170
|
+
PORTAL_NAME,
|
|
171
|
+
self.session,
|
|
172
|
+
SESSION_IFACE,
|
|
173
|
+
"Close",
|
|
174
|
+
None,
|
|
175
|
+
None,
|
|
176
|
+
Gio.DBusCallFlags.NONE,
|
|
177
|
+
3000,
|
|
178
|
+
None,
|
|
179
|
+
)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
self.session = None
|
|
183
|
+
try:
|
|
184
|
+
self.conn.signal_unsubscribe(self.subscription)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class EiSender:
|
|
190
|
+
def __init__(self, fd):
|
|
191
|
+
self.lib = ctypes.CDLL("libei.so.1")
|
|
192
|
+
c_void_p = ctypes.c_void_p
|
|
193
|
+
self.lib.ei_new_sender.argtypes = [c_void_p]
|
|
194
|
+
self.lib.ei_new_sender.restype = c_void_p
|
|
195
|
+
self.lib.ei_configure_name.argtypes = [c_void_p, ctypes.c_char_p]
|
|
196
|
+
self.lib.ei_setup_backend_fd.argtypes = [c_void_p, ctypes.c_int]
|
|
197
|
+
self.lib.ei_setup_backend_fd.restype = ctypes.c_int
|
|
198
|
+
self.lib.ei_get_fd.argtypes = [c_void_p]
|
|
199
|
+
self.lib.ei_get_fd.restype = ctypes.c_int
|
|
200
|
+
self.lib.ei_dispatch.argtypes = [c_void_p]
|
|
201
|
+
self.lib.ei_get_event.argtypes = [c_void_p]
|
|
202
|
+
self.lib.ei_get_event.restype = c_void_p
|
|
203
|
+
self.lib.ei_event_get_type.argtypes = [c_void_p]
|
|
204
|
+
self.lib.ei_event_get_type.restype = ctypes.c_int
|
|
205
|
+
self.lib.ei_event_get_seat.argtypes = [c_void_p]
|
|
206
|
+
self.lib.ei_event_get_seat.restype = c_void_p
|
|
207
|
+
self.lib.ei_event_get_device.argtypes = [c_void_p]
|
|
208
|
+
self.lib.ei_event_get_device.restype = c_void_p
|
|
209
|
+
self.lib.ei_event_unref.argtypes = [c_void_p]
|
|
210
|
+
self.lib.ei_event_unref.restype = c_void_p
|
|
211
|
+
self.lib.ei_device_ref.argtypes = [c_void_p]
|
|
212
|
+
self.lib.ei_device_ref.restype = c_void_p
|
|
213
|
+
self.lib.ei_device_unref.argtypes = [c_void_p]
|
|
214
|
+
self.lib.ei_device_unref.restype = c_void_p
|
|
215
|
+
self.lib.ei_device_has_capability.argtypes = [c_void_p, ctypes.c_int]
|
|
216
|
+
self.lib.ei_device_has_capability.restype = ctypes.c_bool
|
|
217
|
+
self.lib.ei_device_start_emulating.argtypes = [c_void_p, ctypes.c_uint32]
|
|
218
|
+
self.lib.ei_device_stop_emulating.argtypes = [c_void_p]
|
|
219
|
+
self.lib.ei_device_keyboard_key.argtypes = [c_void_p, ctypes.c_uint32, ctypes.c_bool]
|
|
220
|
+
self.lib.ei_device_pointer_motion.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
|
|
221
|
+
self.lib.ei_device_button_button.argtypes = [c_void_p, ctypes.c_uint32, ctypes.c_bool]
|
|
222
|
+
self.lib.ei_device_scroll_discrete.argtypes = [c_void_p, ctypes.c_int32, ctypes.c_int32]
|
|
223
|
+
self.lib.ei_device_frame.argtypes = [c_void_p, ctypes.c_uint64]
|
|
224
|
+
self.lib.ei_now.argtypes = [c_void_p]
|
|
225
|
+
self.lib.ei_now.restype = ctypes.c_uint64
|
|
226
|
+
self.lib.ei_unref.argtypes = [c_void_p]
|
|
227
|
+
self.lib.ei_unref.restype = c_void_p
|
|
228
|
+
self.lib.ei_seat_has_capability.argtypes = [c_void_p, ctypes.c_int]
|
|
229
|
+
self.lib.ei_seat_has_capability.restype = ctypes.c_bool
|
|
230
|
+
# ei_seat_bind_capabilities is varargs: keep argtypes unset.
|
|
231
|
+
self.lib.ei_seat_bind_capabilities.restype = None
|
|
232
|
+
|
|
233
|
+
self.ei = self.lib.ei_new_sender(None)
|
|
234
|
+
if not self.ei:
|
|
235
|
+
raise RuntimeError("ei_new_sender failed")
|
|
236
|
+
self.lib.ei_configure_name(self.ei, b"ReMCP")
|
|
237
|
+
rc = int(self.lib.ei_setup_backend_fd(self.ei, fd))
|
|
238
|
+
if rc < 0:
|
|
239
|
+
raise RuntimeError(f"ei_setup_backend_fd failed ({rc})")
|
|
240
|
+
self.fd = int(self.lib.ei_get_fd(self.ei))
|
|
241
|
+
if self.fd < 0:
|
|
242
|
+
raise RuntimeError("ei_get_fd failed")
|
|
243
|
+
self.devices = {}
|
|
244
|
+
self.sequence = 1
|
|
245
|
+
self.connected = False
|
|
246
|
+
|
|
247
|
+
def _device_key(self, ptr):
|
|
248
|
+
return int(ptr or 0)
|
|
249
|
+
|
|
250
|
+
def _remember_device(self, ptr):
|
|
251
|
+
key = self._device_key(ptr)
|
|
252
|
+
if not key:
|
|
253
|
+
return None
|
|
254
|
+
if key not in self.devices:
|
|
255
|
+
retained = self.lib.ei_device_ref(ptr)
|
|
256
|
+
self.devices[key] = {"ptr": retained, "resumed": False, "emulating": False}
|
|
257
|
+
return self.devices[key]
|
|
258
|
+
|
|
259
|
+
def _drop_device(self, ptr):
|
|
260
|
+
key = self._device_key(ptr)
|
|
261
|
+
state = self.devices.pop(key, None)
|
|
262
|
+
if state:
|
|
263
|
+
if state["emulating"]:
|
|
264
|
+
try:
|
|
265
|
+
self.lib.ei_device_stop_emulating(state["ptr"])
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
self.lib.ei_device_unref(state["ptr"])
|
|
269
|
+
|
|
270
|
+
def _bind_seat(self, seat):
|
|
271
|
+
caps = [EI_CAP_POINTER, EI_CAP_POINTER_ABSOLUTE, EI_CAP_KEYBOARD, EI_CAP_SCROLL, EI_CAP_BUTTON]
|
|
272
|
+
args = [ctypes.c_void_p(seat)]
|
|
273
|
+
args.extend(ctypes.c_int(cap) for cap in caps if self.lib.ei_seat_has_capability(seat, cap))
|
|
274
|
+
args.append(ctypes.c_void_p())
|
|
275
|
+
self.lib.ei_seat_bind_capabilities(*args)
|
|
276
|
+
|
|
277
|
+
def process_events(self):
|
|
278
|
+
self.lib.ei_dispatch(self.ei)
|
|
279
|
+
while True:
|
|
280
|
+
event = self.lib.ei_get_event(self.ei)
|
|
281
|
+
if not event:
|
|
282
|
+
break
|
|
283
|
+
try:
|
|
284
|
+
event_type = int(self.lib.ei_event_get_type(event))
|
|
285
|
+
if event_type == EI_EVENT_CONNECT:
|
|
286
|
+
self.connected = True
|
|
287
|
+
elif event_type == EI_EVENT_DISCONNECT:
|
|
288
|
+
raise RuntimeError("EIS server disconnected")
|
|
289
|
+
elif event_type == EI_EVENT_SEAT_ADDED:
|
|
290
|
+
seat = self.lib.ei_event_get_seat(event)
|
|
291
|
+
if seat:
|
|
292
|
+
self._bind_seat(seat)
|
|
293
|
+
elif event_type == EI_EVENT_DEVICE_ADDED:
|
|
294
|
+
device = self.lib.ei_event_get_device(event)
|
|
295
|
+
if device:
|
|
296
|
+
self._remember_device(device)
|
|
297
|
+
elif event_type == EI_EVENT_DEVICE_RESUMED:
|
|
298
|
+
device = self.lib.ei_event_get_device(event)
|
|
299
|
+
state = self._remember_device(device)
|
|
300
|
+
if state:
|
|
301
|
+
state["resumed"] = True
|
|
302
|
+
if not state["emulating"]:
|
|
303
|
+
self.lib.ei_device_start_emulating(state["ptr"], self.sequence)
|
|
304
|
+
self.sequence = (self.sequence + 1) & 0xFFFFFFFF or 1
|
|
305
|
+
state["emulating"] = True
|
|
306
|
+
elif event_type == EI_EVENT_DEVICE_PAUSED:
|
|
307
|
+
device = self.lib.ei_event_get_device(event)
|
|
308
|
+
state = self.devices.get(self._device_key(device))
|
|
309
|
+
if state:
|
|
310
|
+
state["resumed"] = False
|
|
311
|
+
state["emulating"] = False
|
|
312
|
+
elif event_type == EI_EVENT_DEVICE_REMOVED:
|
|
313
|
+
self._drop_device(self.lib.ei_event_get_device(event))
|
|
314
|
+
finally:
|
|
315
|
+
self.lib.ei_event_unref(event)
|
|
316
|
+
|
|
317
|
+
def wait_ready(self, timeout_s=10.0):
|
|
318
|
+
deadline = time.monotonic() + timeout_s
|
|
319
|
+
while time.monotonic() < deadline:
|
|
320
|
+
if (
|
|
321
|
+
self.connected
|
|
322
|
+
and self.capability_ready(EI_CAP_KEYBOARD)
|
|
323
|
+
and self.capability_ready(EI_CAP_POINTER)
|
|
324
|
+
and self.capability_ready(EI_CAP_BUTTON)
|
|
325
|
+
and self.capability_ready(EI_CAP_SCROLL)
|
|
326
|
+
):
|
|
327
|
+
return
|
|
328
|
+
readable, _, _ = select.select([self.fd], [], [], min(0.1, max(0.0, deadline - time.monotonic())))
|
|
329
|
+
if readable:
|
|
330
|
+
self.process_events()
|
|
331
|
+
raise TimeoutError(f"EIS devices did not become ready; capabilities={self.capabilities()}")
|
|
332
|
+
|
|
333
|
+
def capability_ready(self, cap):
|
|
334
|
+
return any(state["resumed"] and self.lib.ei_device_has_capability(state["ptr"], cap) for state in self.devices.values())
|
|
335
|
+
|
|
336
|
+
def capabilities(self):
|
|
337
|
+
names = {
|
|
338
|
+
EI_CAP_POINTER: "pointer",
|
|
339
|
+
EI_CAP_POINTER_ABSOLUTE: "pointer_absolute",
|
|
340
|
+
EI_CAP_KEYBOARD: "keyboard",
|
|
341
|
+
EI_CAP_SCROLL: "scroll",
|
|
342
|
+
EI_CAP_BUTTON: "button",
|
|
343
|
+
}
|
|
344
|
+
return {name: self.capability_ready(cap) for cap, name in names.items()}
|
|
345
|
+
|
|
346
|
+
def device_for(self, cap):
|
|
347
|
+
for state in self.devices.values():
|
|
348
|
+
if state["resumed"] and state["emulating"] and self.lib.ei_device_has_capability(state["ptr"], cap):
|
|
349
|
+
return state["ptr"]
|
|
350
|
+
raise RuntimeError(f"no resumed EIS device with capability {cap}")
|
|
351
|
+
|
|
352
|
+
def frame(self, device):
|
|
353
|
+
self.lib.ei_device_frame(device, self.lib.ei_now(self.ei))
|
|
354
|
+
|
|
355
|
+
def command(self, payload):
|
|
356
|
+
op = str(payload.get("op") or "")
|
|
357
|
+
if op == "key":
|
|
358
|
+
device = self.device_for(EI_CAP_KEYBOARD)
|
|
359
|
+
self.lib.ei_device_keyboard_key(device, int(payload["keycode"]), bool(payload.get("pressed")))
|
|
360
|
+
self.frame(device)
|
|
361
|
+
elif op == "motion":
|
|
362
|
+
device = self.device_for(EI_CAP_POINTER)
|
|
363
|
+
self.lib.ei_device_pointer_motion(device, float(payload.get("dx", 0)), float(payload.get("dy", 0)))
|
|
364
|
+
self.frame(device)
|
|
365
|
+
elif op == "button":
|
|
366
|
+
device = self.device_for(EI_CAP_BUTTON)
|
|
367
|
+
self.lib.ei_device_button_button(device, int(payload["button"]), bool(payload.get("pressed")))
|
|
368
|
+
self.frame(device)
|
|
369
|
+
elif op == "scroll":
|
|
370
|
+
device = self.device_for(EI_CAP_SCROLL)
|
|
371
|
+
self.lib.ei_device_scroll_discrete(device, int(payload.get("dx", 0)), int(payload.get("dy", 0)))
|
|
372
|
+
self.frame(device)
|
|
373
|
+
elif op == "ping":
|
|
374
|
+
pass
|
|
375
|
+
elif op == "close":
|
|
376
|
+
return False
|
|
377
|
+
else:
|
|
378
|
+
raise ValueError(f"unsupported EIS operation: {op}")
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
def close(self):
|
|
382
|
+
for state in list(self.devices.values()):
|
|
383
|
+
if state["emulating"]:
|
|
384
|
+
try:
|
|
385
|
+
self.lib.ei_device_stop_emulating(state["ptr"])
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
try:
|
|
389
|
+
self.lib.ei_device_unref(state["ptr"])
|
|
390
|
+
except Exception:
|
|
391
|
+
pass
|
|
392
|
+
self.devices.clear()
|
|
393
|
+
if self.ei:
|
|
394
|
+
self.lib.ei_unref(self.ei)
|
|
395
|
+
self.ei = None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def main():
|
|
399
|
+
timeout_ms = max(1000, min(120000, int(os.environ.get("REMCP_EIS_TIMEOUT_MS", "30000"))))
|
|
400
|
+
restore_token = os.environ.get("REMCP_EIS_RESTORE_TOKEN", "")
|
|
401
|
+
parent_window = os.environ.get("REMCP_EIS_PARENT_WINDOW", "")
|
|
402
|
+
portal = None
|
|
403
|
+
sender = None
|
|
404
|
+
try:
|
|
405
|
+
portal = Portal(timeout_ms)
|
|
406
|
+
devices, new_restore_token = portal.open(restore_token, parent_window)
|
|
407
|
+
eis_fd = portal.connect_eis()
|
|
408
|
+
sender = EiSender(eis_fd)
|
|
409
|
+
sender.wait_ready(min(10.0, timeout_ms / 1000.0))
|
|
410
|
+
emit({
|
|
411
|
+
"ready": True,
|
|
412
|
+
"backend": "xdg-eis",
|
|
413
|
+
"devices": devices,
|
|
414
|
+
"restore_token": new_restore_token,
|
|
415
|
+
"capabilities": sender.capabilities(),
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
running = True
|
|
419
|
+
while running:
|
|
420
|
+
readable, _, _ = select.select([sys.stdin, sender.fd], [], [], 0.5)
|
|
421
|
+
if sender.fd in readable:
|
|
422
|
+
sender.process_events()
|
|
423
|
+
if sys.stdin in readable:
|
|
424
|
+
line = sys.stdin.readline()
|
|
425
|
+
if line == "":
|
|
426
|
+
break
|
|
427
|
+
request = None
|
|
428
|
+
try:
|
|
429
|
+
request = json.loads(line)
|
|
430
|
+
request_id = request.get("id")
|
|
431
|
+
running = sender.command(request)
|
|
432
|
+
emit({"id": request_id, "ok": True, "capabilities": sender.capabilities()})
|
|
433
|
+
except Exception as exc:
|
|
434
|
+
emit({"id": request.get("id") if isinstance(request, dict) else None, "ok": False, "error": str(exc)})
|
|
435
|
+
except OSError as exc:
|
|
436
|
+
emit({"ready": False, "kind": "unsupported", "error": f"libei unavailable: {exc}"})
|
|
437
|
+
return 78
|
|
438
|
+
except Exception as exc:
|
|
439
|
+
emit({"ready": False, "kind": "runtime", "error": str(exc)})
|
|
440
|
+
return 1
|
|
441
|
+
finally:
|
|
442
|
+
if sender:
|
|
443
|
+
sender.close()
|
|
444
|
+
if portal:
|
|
445
|
+
portal.close()
|
|
446
|
+
return 0
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
sys.exit(main())
|
package/src/index.mjs
CHANGED
|
@@ -2,18 +2,25 @@
|
|
|
2
2
|
import process from 'node:process';
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { advertisedTools, toolDefinitions } from './catalog.mjs';
|
|
5
|
+
import { advertisedTools as advertisedCoreTools, toolDefinitions as coreToolDefinitions } from './catalog.mjs';
|
|
6
|
+
import { allExtendedTools, advertisedExtendedTools, extendedToolDefinitions } from './extended/catalog.mjs';
|
|
6
7
|
import { describeConfig, configurationError, runtimeConfigDir } from './config.mjs';
|
|
7
|
-
import { invokeTool } from './invoke.mjs';
|
|
8
8
|
import { shutdownSessions, startSessionSweeper } from './sessions.mjs';
|
|
9
9
|
import { flush, setTelemetrySink, shutdownTelemetry, telemetryEnabled } from './telemetry.mjs';
|
|
10
10
|
import { VERSION } from './version.mjs';
|
|
11
11
|
|
|
12
|
-
// The MCP SDK is imported lazily so that `--help`, `--version`, `--print-tools` and
|
|
13
|
-
// `--describe` work from a bare checkout or a published tarball with no node_modules.
|
|
14
|
-
// That is what lets CI diff the advertised tool contract against the package users get.
|
|
15
|
-
|
|
16
12
|
const args = process.argv.slice(2);
|
|
13
|
+
const allToolDefinitions = [...coreToolDefinitions, ...extendedToolDefinitions];
|
|
14
|
+
|
|
15
|
+
async function advertisedRuntimeTools() {
|
|
16
|
+
return [...advertisedCoreTools(), ...await advertisedExtendedTools()];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function writeStdout(value) {
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
process.stdout.write(value, error => error ? reject(error) : resolve());
|
|
22
|
+
});
|
|
23
|
+
}
|
|
17
24
|
|
|
18
25
|
if (args.includes('--help') || args.includes('-h')) {
|
|
19
26
|
process.stdout.write([
|
|
@@ -22,7 +29,10 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
22
29
|
'Usage: remcp-runtime [--print-tools] [--describe] [--version]',
|
|
23
30
|
'',
|
|
24
31
|
'The runtime is normally started by the ReMCP device agent and speaks MCP over stdio.',
|
|
25
|
-
'It executes file, search, terminal, and
|
|
32
|
+
'It executes file, search, terminal, process, desktop UI, browser, diagnostics, and document tools locally on this computer.',
|
|
33
|
+
'',
|
|
34
|
+
'Desktop capabilities are detected from the operating system and available native helpers.',
|
|
35
|
+
'The live MCP tool list changes when optional capabilities appear or disappear.',
|
|
26
36
|
'',
|
|
27
37
|
'Usage metrics are opt-out: only tool names, timings and outcomes are collected, and they',
|
|
28
38
|
'travel to your own ReMCP account through the already-authenticated paired agent. Nothing is',
|
|
@@ -40,18 +50,22 @@ if (args.includes('--version')) {
|
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
if (args.includes('--print-tools')) {
|
|
43
|
-
|
|
53
|
+
await writeStdout(`${JSON.stringify([...advertisedCoreTools(), ...allExtendedTools()], null, 2)}\n`);
|
|
44
54
|
process.exit(0);
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
if (args.includes('--describe')) {
|
|
48
|
-
|
|
58
|
+
const supportedTools = (await advertisedRuntimeTools()).length;
|
|
59
|
+
await writeStdout(`${JSON.stringify({
|
|
60
|
+
version: VERSION,
|
|
61
|
+
tools: allToolDefinitions.length,
|
|
62
|
+
supportedTools,
|
|
63
|
+
extendedTools: extendedToolDefinitions.length,
|
|
64
|
+
...describeConfig(),
|
|
65
|
+
}, null, 2)}\n`);
|
|
49
66
|
process.exit(0);
|
|
50
67
|
}
|
|
51
68
|
|
|
52
|
-
// A configuration file the user cannot read must not be ignored: that is how allowedRoots
|
|
53
|
-
// and an opt-out quietly disappear. Metadata commands above still work, so an operator can
|
|
54
|
-
// inspect the device; the server itself refuses to start.
|
|
55
69
|
const configProblem = configurationError();
|
|
56
70
|
if (configProblem) {
|
|
57
71
|
console.error(`ReMCP runtime refuses to start: ${configProblem}`);
|
|
@@ -69,33 +83,26 @@ function announceTelemetryOnce() {
|
|
|
69
83
|
console.error('ReMCP runtime: anonymous usage metrics are on (tool names, timings, outcomes only - never file paths, commands or output). They go to your own ReMCP account through the paired agent. Disable with `remcp telemetry off`.');
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
const runtimeInstructions = [
|
|
87
|
+
'This runtime executes file, search, terminal, process, desktop UI, browser, diagnostics, and document tools locally on a computer its owner paired with ReMCP.',
|
|
88
|
+
'Inspect before changing: read, snapshot, list or find first; then write, edit, click, move, launch, or run commands only when the user asked for that side effect.',
|
|
89
|
+
'Prefer accessibility or browser DOM targets over screen coordinates. Use screenshots as a visual fallback rather than the primary control plane.',
|
|
90
|
+
'Browser CDP endpoints are loopback-only; local file uploads and document operations still obey the runtime filesystem allowlist.',
|
|
91
|
+
'Paths are absolute or resolve against the runtime working directory; prefer absolute paths.',
|
|
92
|
+
'Commands matching the configured catastrophic-command guardrail are handled by the same policy layer as terminal commands.',
|
|
93
|
+
].join(' ');
|
|
94
|
+
|
|
95
|
+
const { startRuntimeMcpServer } = await import('./mcp-v2.mjs');
|
|
96
|
+
const runtimeServer = await startRuntimeMcpServer({
|
|
97
|
+
version: VERSION,
|
|
98
|
+
instructions: runtimeInstructions,
|
|
99
|
+
onError(error) {
|
|
100
|
+
console.error(`ReMCP runtime MCP error: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
101
|
},
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: advertisedTools() }));
|
|
90
|
-
|
|
91
|
-
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
92
|
-
return invokeTool(request.params.name, request.params.arguments, extra);
|
|
93
102
|
});
|
|
94
103
|
|
|
95
|
-
// Telemetry leaves this process only as an MCP notification to the agent that started
|
|
96
|
-
// it. The runtime never opens a network connection of its own.
|
|
97
104
|
setTelemetrySink(async payload => {
|
|
98
|
-
await
|
|
105
|
+
await runtimeServer.notification({ method: 'notifications/remcp/telemetry', params: payload });
|
|
99
106
|
});
|
|
100
107
|
|
|
101
108
|
const SHUTDOWN_BUDGET_MS = 1500;
|
|
@@ -106,19 +113,14 @@ async function shutdown(code = 0) {
|
|
|
106
113
|
shutdownStarted = true;
|
|
107
114
|
const deadline = Date.now() + SHUTDOWN_BUDGET_MS;
|
|
108
115
|
shutdownSessions();
|
|
109
|
-
// The SDK gives a closing server about two seconds before it kills the process, so the
|
|
110
|
-
// telemetry flush is bounded and awaited instead of fire-and-forget: an unawaited
|
|
111
|
-
// notification() after close is an unhandled rejection.
|
|
112
116
|
try {
|
|
113
117
|
await Promise.race([flush(), new Promise(resolve => setTimeout(resolve, Math.max(0, deadline - Date.now())))]);
|
|
114
118
|
} catch {}
|
|
115
119
|
await shutdownTelemetry();
|
|
116
|
-
try { await
|
|
120
|
+
try { await runtimeServer.close(); } catch {}
|
|
117
121
|
process.exit(code);
|
|
118
122
|
}
|
|
119
123
|
|
|
120
|
-
// A dead agent leaves a broken stdout pipe. Without this the process died on an
|
|
121
|
-
// uncaught EPIPE with a stack trace and left its terminal children behind.
|
|
122
124
|
process.stdout.on('error', error => {
|
|
123
125
|
if (error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED') void shutdown(0);
|
|
124
126
|
else console.error(`ReMCP runtime stdout error: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -134,8 +136,8 @@ process.on('SIGINT', () => void shutdown(0));
|
|
|
134
136
|
process.on('SIGTERM', () => void shutdown(0));
|
|
135
137
|
process.on('exit', () => { shutdownSessions(); });
|
|
136
138
|
|
|
137
|
-
const transport = new StdioServerTransport();
|
|
138
|
-
await server.connect(transport);
|
|
139
139
|
startSessionSweeper();
|
|
140
140
|
announceTelemetryOnce();
|
|
141
|
-
|
|
141
|
+
|
|
142
|
+
const supportedCount = (await advertisedRuntimeTools()).length;
|
|
143
|
+
console.error(`ReMCP runtime ${VERSION} ready with ${supportedCount}/${allToolDefinitions.length} tools supported on this computer`);
|
package/src/invoke.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { toolHandlers } from './catalog.mjs';
|
|
2
|
+
import { extendedToolHandlers } from './extended/catalog.mjs';
|
|
2
3
|
import { describeFilesystemFailure } from './permissions.mjs';
|
|
3
4
|
import { recordEvent } from './telemetry.mjs';
|
|
4
5
|
import { ToolError, text } from './util.mjs';
|
|
5
6
|
|
|
6
7
|
export function hasTool(name) {
|
|
7
|
-
return toolHandlers.has(name);
|
|
8
|
+
return toolHandlers.has(name) || extendedToolHandlers.has(name);
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
function errorKind(error) {
|
|
@@ -15,7 +16,7 @@ function errorKind(error) {
|
|
|
15
16
|
|
|
16
17
|
export async function invokeTool(name, args = {}, extra = {}) {
|
|
17
18
|
const started = performance.now();
|
|
18
|
-
const definition = toolHandlers.get(name);
|
|
19
|
+
const definition = toolHandlers.get(name) || extendedToolHandlers.get(name);
|
|
19
20
|
if (!definition) {
|
|
20
21
|
recordEvent('tool_call', { tool: 'unknown_tool', success: false, errorKind: 'unknown_tool', durationMs: 0 });
|
|
21
22
|
return text(`Unknown tool: ${name}`, true);
|
|
@@ -30,7 +31,6 @@ export async function invokeTool(name, args = {}, extra = {}) {
|
|
|
30
31
|
} catch (error) {
|
|
31
32
|
recordEvent('tool_call', { tool: definition.name, durationMs: performance.now() - started, success: false, errorKind: errorKind(error) });
|
|
32
33
|
if (error instanceof ToolError) return text(error.message, true);
|
|
33
|
-
// A filesystem error arrives with a bare errno; the explanation is what the person can act on.
|
|
34
34
|
return text(`Tool ${name} failed: ${describeFilesystemFailure(error, { path: error?.path })}`, true);
|
|
35
35
|
}
|
|
36
36
|
}
|