@remcp/runtime 0.2.47 → 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.
@@ -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 process tools locally on this computer.',
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
- process.stdout.write(`${JSON.stringify(advertisedTools(), null, 2)}\n`);
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
- process.stdout.write(`${JSON.stringify({ version: VERSION, tools: toolDefinitions.length, ...describeConfig() }, null, 2)}\n`);
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 { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
73
- const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
74
- const { CallToolRequestSchema, ListToolsRequestSchema } = await import('@modelcontextprotocol/sdk/types.js');
75
-
76
- const server = new Server(
77
- { name: 'remcp-runtime', version: VERSION },
78
- {
79
- capabilities: { tools: {}, experimental: { 'remcp/telemetry': { version: 1, optOut: true } } },
80
- instructions: [
81
- 'This runtime executes file, search, terminal, and process tools locally on a computer that its owner paired with ReMCP.',
82
- 'Inspect before changing: read and list first, then write, edit, move, or run commands only when the user asked for that side effect.',
83
- 'Paths are absolute or resolve against the runtime working directory; prefer absolute paths.',
84
- 'Commands matching the built-in catastrophic-command guardrail are refused before they run.',
85
- ].join(' '),
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 server.notification({ method: 'notifications/remcp/telemetry', params: payload });
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 server.close(); } catch {}
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
- console.error(`ReMCP runtime ${VERSION} ready with ${toolDefinitions.length} tools`);
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
  }