@remcp/remcp 0.1.3 → 0.2.4

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.
Files changed (56) hide show
  1. package/README.md +41 -113
  2. package/package.json +10 -24
  3. package/src/agent.mjs +215 -36
  4. package/src/cli.mjs +217 -43
  5. package/src/runtime.mjs +15 -0
  6. package/src/version.mjs +1 -3
  7. package/THIRD_PARTY_NOTICES.md +0 -7
  8. package/assets/remcp-icon.png +0 -0
  9. package/mcp.json +0 -9
  10. package/plugin.json +0 -37
  11. package/public/android-chrome-192x192.png +0 -0
  12. package/public/android-chrome-512x512.png +0 -0
  13. package/public/app.js +0 -96
  14. package/public/apple-touch-icon.png +0 -0
  15. package/public/assets/art/hero-relay.webp +0 -0
  16. package/public/assets/art/relay-detail.webp +0 -0
  17. package/public/assets/icon-120.png +0 -0
  18. package/public/assets/icon-16.png +0 -0
  19. package/public/assets/icon-180.png +0 -0
  20. package/public/assets/icon-192.png +0 -0
  21. package/public/assets/icon-256.png +0 -0
  22. package/public/assets/icon-32.png +0 -0
  23. package/public/assets/icon-48.png +0 -0
  24. package/public/assets/icon-512.png +0 -0
  25. package/public/assets/og-remcp.png +0 -0
  26. package/public/authorize.html +0 -59
  27. package/public/copy.js +0 -40
  28. package/public/docs.html +0 -93
  29. package/public/favicon-16x16.png +0 -0
  30. package/public/favicon-32x32.png +0 -0
  31. package/public/favicon-48x48.png +0 -0
  32. package/public/favicon.ico +0 -0
  33. package/public/index.html +0 -134
  34. package/public/oauth-app.js +0 -63
  35. package/public/privacy.html +0 -80
  36. package/public/remcp-darkmode-16x16.png +0 -0
  37. package/public/remcp-darkmode-192x192.png +0 -0
  38. package/public/remcp-darkmode-32x32.png +0 -0
  39. package/public/remcp-darkmode-48x48.png +0 -0
  40. package/public/remcp-darkmode-512x512.png +0 -0
  41. package/public/security.html +0 -70
  42. package/public/site.webmanifest +0 -23
  43. package/public/style.css +0 -261
  44. package/public/support.html +0 -75
  45. package/public/terms.html +0 -77
  46. package/skills/remcp-operator/SKILL.md +0 -36
  47. package/src/auth.mjs +0 -71
  48. package/src/config.mjs +0 -51
  49. package/src/db.mjs +0 -102
  50. package/src/mcp.mjs +0 -95
  51. package/src/oauth.mjs +0 -164
  52. package/src/relay.mjs +0 -97
  53. package/src/review-sandbox.mjs +0 -96
  54. package/src/server.mjs +0 -159
  55. package/src/tool-catalog.json +0 -796
  56. package/src/util.mjs +0 -24
package/README.md CHANGED
@@ -1,143 +1,71 @@
1
- # ReMCP
1
+ # Device client
2
2
 
3
- ReMCP is an open-source remote MCP bridge for computers you control. A lightweight device agent connects **outbound** to a ReMCP relay, so ChatGPT, Codex, or another MCP client can work with local files, directories, searches, terminal sessions, and processes without exposing an inbound port on the computer.
4
-
5
- - Hosted service: <https://remcp.delio24.com>
6
- - MCP endpoint: `https://remcp.delio24.com/mcp`
7
- - npm: `@remcp/remcp`
8
- - License: MIT
9
-
10
- ## Quick start
11
-
12
- Requires Node.js 22.5 or newer.
13
-
14
- 1. Install the CLI:
3
+ `@remcp/remcp` is the ReMCP device client. It pairs a computer with ReMCP, installs the first-party
4
+ local runtime, and runs the outbound-only agent that serves your MCP client's tool calls.
15
5
 
16
6
  ```bash
17
7
  npm install --global @remcp/remcp@latest
18
- ```
19
-
20
- 2. Open <https://remcp.delio24.com/#connect>, sign in with Google or GitHub, and choose **Generate pairing command**. Run the exact generated command on the computer you want to pair.
21
-
22
- 3. Verify:
23
-
24
- ```bash
25
8
  remcp --version
26
9
  remcp status
27
10
  ```
28
11
 
29
- 4. Add the MCP endpoint to your client:
30
-
31
- ```text
32
- https://remcp.delio24.com/mcp
33
- ```
34
-
35
- Authentication uses OAuth authorization code + PKCE with rotating refresh credentials.
36
-
37
- ## Update
38
-
39
- ```bash
40
- remcp update
41
- ```
42
-
43
- `remcp update` refreshes the global CLI and compatible local runtime, then restarts the installed user service.
12
+ Pairing commands are generated in the workspace at <https://remcp.delio24.com/app/connect>. The
13
+ generated command runs `remcp connect --server … --code … --install`, which stores a per-device
14
+ credential under `~/.config/remcp/`, installs the runtime from npm, and registers a user service
15
+ (systemd on Linux, LaunchAgent on macOS, Scheduled Task on Windows).
44
16
 
45
- ## CLI
17
+ ## Commands
46
18
 
47
19
  ```text
48
- remcp start Run the device agent in the foreground
49
- remcp status Show version, pairing and server health
50
- remcp doctor Alias for status
51
- remcp update Update ReMCP and restart the user service
52
- remcp install Install or repair the user service
53
- remcp uninstall Remove the user service
54
- remcp uninstall --purge Remove the service and global ReMCP packages
55
- remcp --version Print the installed CLI version
56
- ```
57
-
58
- ## Architecture
59
-
60
- ```text
61
- ChatGPT / Codex / MCP client
62
-
63
- │ OAuth 2.1 + HTTPS
64
-
65
- ReMCP relay /mcp
66
-
67
- │ authenticated WSS (outbound from device)
68
-
69
- paired computer
70
-
71
- └── local file / search / terminal / process runtime
20
+ remcp start Run the device agent in the foreground
21
+ remcp status Show version, pairing, runtime, telemetry and server health as JSON
22
+ remcp doctor Alias for status
23
+ remcp update Update the client and runtime, then restart the user service
24
+ remcp install Install or repair the user service
25
+ remcp uninstall Remove the user service
26
+ remcp uninstall --purge Remove the service and the global packages
27
+ remcp telemetry [status|on|off]
28
+ remcp --version
72
29
  ```
73
30
 
74
- The hosted relay stores account/device metadata, one-way hashes of reusable credentials, OAuth client metadata, and refresh-token hashes. Device credentials are independently revocable. MCP sessions are scoped to the authenticated ReMCP account.
31
+ `remcp status` reports the runtime the device would install, whether the agent service is running,
32
+ and the current usage-metrics state, so a support request can be answered with one paste.
75
33
 
76
- ## OpenAI Plugin package
34
+ ## What runs on your computer
77
35
 
78
- The repository is prepared for the current OpenAI Plugins format:
36
+ - the agent (`remcp start`), which holds the device credential and dials
37
+ `wss://remcp.delio24.com/agent`;
38
+ - [`@remcp/runtime`](https://www.npmjs.com/package/@remcp/runtime), spawned by the agent as an MCP
39
+ stdio server. The runtime executes the tools, opens no network connection, and is supervised: if it
40
+ exits, the agent restarts it with backoff and reports the restart instead of failing silently.
79
41
 
80
- - `plugin.json` portable Agent Plugins manifest;
81
- - `mcp.json` production Streamable HTTP MCP configuration;
82
- - `skills/remcp-operator/SKILL.md` bundled operator skill;
83
- - `assets/remcp-icon.png` — plugin icon;
84
- - `docs/OPENAI_PLUGIN.md` — submission and reviewer checklist;
85
- - `submission/openai-plugin-review.json` — generated tool annotations plus five positive and three negative review cases.
42
+ Configuration lives in `~/.config/remcp/config.json` (client) and `~/.config/remcp/runtime.json`
43
+ (runtime: allowed roots, blocked commands, output and write limits, and the usage-metrics switch).
44
+ Run `npx @remcp/runtime --describe` to print the runtime's effective configuration.
86
45
 
87
- ReMCP has no plugin UI, so the public submission should not include UI screenshots. The remote MCP server uses a universal production URL and OAuth. See [`docs/OPENAI_PLUGIN.md`](docs/OPENAI_PLUGIN.md) before submitting.
46
+ ## Usage metrics
88
47
 
89
- ## Security model
48
+ Opt-out, self-hosted, and limited to tool names, timings, outcomes, error classes, and device health
49
+ samples. No paths, no commands, no arguments, no output, no third-party endpoint, no install ping.
50
+ Disable with `remcp telemetry off` or `REMCP_RUNTIME_DISABLE_TELEMETRY=1`; the switch applies to both
51
+ the client and the runtime, and restarts the service so it takes effect immediately.
90
52
 
91
- - no inbound port is required on paired computers;
92
- - one revocable credential is issued per paired device;
93
- - reusable credentials are hashed server-side;
94
- - pairing codes are short-lived and single-use;
95
- - OAuth uses PKCE and rotating refresh credentials;
96
- - OAuth metadata advertises `openid` and `email`, with a UserInfo endpoint for verified-email workspace restrictions;
97
- - MCP tools carry explicit `readOnlyHint`, `openWorldHint`, and `destructiveHint` annotations;
98
- - the public review fixture is isolated per authenticated account and intentionally restricts terminal commands;
99
- - the supplied server container runs read-only with dropped Linux capabilities.
53
+ ## Security
100
54
 
101
- ReMCP tools should not be used to request or process passwords, MFA codes, API/private keys, payment-card data, protected health information, or government identifiers. See [`SECURITY.md`](SECURITY.md) and the hosted <https://remcp.delio24.com/security> page.
102
-
103
- ## Self-hosting
104
-
105
- ```bash
106
- git clone https://github.com/antonbaider/remcp.git
107
- cd remcp
108
- cp .env.example .env
109
- # Fill in your public URL and Firebase Web App configuration.
110
- docker compose up -d --build
111
- ```
112
-
113
- Put a TLS reverse proxy in front of ReMCP and proxy WebSocket upgrades for `/agent`. The supplied Compose configuration binds the application to localhost by default. ReMCP verifies Firebase ID tokens using Google's public signing keys and does not require a Firebase Admin private key.
55
+ - Outbound-only connection; the machine never listens.
56
+ - One revocable credential per paired device, stored with restrictive permissions.
57
+ - Runtime metadata from a custom server is only accepted with an explicit `--trust-runtime`.
58
+ - Revoking a device in the workspace closes the connection; the agent then stops retrying and says so
59
+ instead of reconnecting forever.
114
60
 
115
61
  ## Development
116
62
 
117
63
  ```bash
118
- npm ci
64
+ npm install
119
65
  npm run check
120
66
  npm test
121
- npm audit --omit=dev
122
- npm pack --dry-run
123
- ```
124
-
125
- For server changes:
126
-
127
- ```bash
128
- docker build -t remcp:test .
129
67
  ```
130
68
 
131
- See [`CONTRIBUTING.md`](CONTRIBUTING.md) for contribution and security rules.
132
-
133
- ## Project docs
134
-
135
- - [`docs/OPENAI_PLUGIN.md`](docs/OPENAI_PLUGIN.md) — OpenAI Plugins submission
136
- - [`SECURITY.md`](SECURITY.md) — vulnerability reporting and operational security
137
- - [`CONTRIBUTING.md`](CONTRIBUTING.md) — development checks
138
- - [`CHANGELOG.md`](CHANGELOG.md) — release notes
139
- - [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) — third-party license notices
140
-
141
69
  ## License
142
70
 
143
- ReMCP is MIT licensed. Third-party notices for the local device runtime are preserved in `THIRD_PARTY_NOTICES.md`.
71
+ MIT.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.1.3",
4
- "description": "Self-hosted remote MCP bridge for securely controlling your own computers from MCP clients.",
3
+ "version": "0.2.4",
4
+ "description": "ReMCP device client: pair a computer with ReMCP and run the outbound-only agent that hosts the local MCP runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://remcp.delio24.com",
@@ -14,37 +14,21 @@
14
14
  "files": [
15
15
  "bin",
16
16
  "src",
17
- "public",
18
- "plugin.json",
19
- "mcp.json",
20
- "assets",
21
- "skills/remcp-operator",
22
17
  "LICENSE",
23
- "THIRD_PARTY_NOTICES.md",
24
18
  "README.md"
25
19
  ],
26
20
  "scripts": {
27
- "server": "node src/server.mjs",
28
- "agent": "node src/agent.mjs",
29
- "check": "node --check src/server.mjs && node --check src/agent.mjs && node --check src/cli.mjs",
30
- "test": "node --test test/*.test.mjs",
31
- "submission": "node scripts/generate-submission.mjs"
21
+ "check": "node --check bin/remcp.mjs && node --check src/cli.mjs && node --check src/agent.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
22
+ "test": "node --test test/*.test.mjs"
32
23
  },
33
24
  "dependencies": {
34
25
  "@modelcontextprotocol/sdk": "^1.30.0",
35
- "express": "^5.2.1",
36
- "express-rate-limit": "^8.7.0",
37
- "helmet": "^8.3.0",
38
- "jose": "^6.2.12",
39
26
  "ws": "^8.21.3"
40
27
  },
41
- "overrides": {
42
- "sharp": "0.35.4",
43
- "uuid": "11.1.1"
44
- },
45
28
  "repository": {
46
29
  "type": "git",
47
- "url": "git+https://github.com/antonbaider/remcp.git"
30
+ "url": "git+https://github.com/antonbaider/remcp.git",
31
+ "directory": "packages/client"
48
32
  },
49
33
  "bugs": {
50
34
  "url": "https://github.com/antonbaider/remcp/issues"
@@ -54,11 +38,13 @@
54
38
  },
55
39
  "keywords": [
56
40
  "mcp",
41
+ "model-context-protocol",
57
42
  "remote",
58
43
  "computer",
59
- "filesystem",
60
- "terminal",
44
+ "cli",
45
+ "agent",
61
46
  "chatgpt",
47
+ "codex",
62
48
  "self-hosted"
63
49
  ]
64
50
  }
package/src/agent.mjs CHANGED
@@ -2,23 +2,59 @@ import os from 'node:os';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
- import { fileURLToPath } from 'node:url';
5
+ import { spawnSync } from 'node:child_process';
6
6
  import WebSocket from 'ws';
7
7
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
8
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
9
+ import { normalizeRuntime } from './runtime.mjs';
9
10
  import { VERSION } from './version.mjs';
10
11
 
11
- function desktopCommanderEntry() {
12
- try { return fileURLToPath(import.meta.resolve('@wonderwhy-er/desktop-commander')); } catch {}
13
- const configured = String(process.env.REMCP_DESKTOP_COMMANDER_ENTRY || '').trim();
14
- const candidates = [
15
- configured,
16
- '/usr/lib/node_modules/@wonderwhy-er/desktop-commander/dist/index.js',
17
- '/usr/local/lib/node_modules/@wonderwhy-er/desktop-commander/dist/index.js',
18
- path.join(os.homedir(), '.hermes/node/lib/node_modules/@wonderwhy-er/desktop-commander/dist/index.js'),
19
- ].filter(Boolean);
20
- for (const candidate of candidates) if (existsSync(candidate)) return candidate;
21
- throw new Error('@wonderwhy-er/desktop-commander is not installed');
12
+ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
13
+ const METRICS_INTERVAL_MS = 60_000;
14
+ const TELEMETRY_QUEUE_LIMIT = 500;
15
+ const TELEMETRY_BATCH_LIMIT = 100;
16
+ const TELEMETRY_SEND_INTERVAL_MS = 5_000;
17
+ const RECONNECT_BASE_MS = 2_000;
18
+ const RECONNECT_MAX_MS = 60_000;
19
+ const RUNTIME_RESTART_BASE_MS = 1_000;
20
+ const RUNTIME_RESTART_MAX_MS = 30_000;
21
+ // Below the relay's RPC timeout so the model gets a real error instead of a client-side
22
+ // timeout while the device keeps working invisibly.
23
+ const CALL_TIMEOUT_MARGIN_MS = 10_000;
24
+
25
+ function globalNodeModules() {
26
+ const result = spawnSync(npmCommand, ['root', '--global'], { encoding: 'utf8' });
27
+ if (result.error || result.status !== 0) throw new Error('Could not locate the global npm modules directory');
28
+ return String(result.stdout || '').trim();
29
+ }
30
+
31
+ function localRuntimeEntry(runtimeValue) {
32
+ const runtime = normalizeRuntime(runtimeValue);
33
+ const candidate = path.join(globalNodeModules(), ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
34
+ if (!existsSync(candidate)) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
35
+ return candidate;
36
+ }
37
+
38
+ function jitter(ms) {
39
+ return Math.round(ms * (0.75 + Math.random() * 0.5));
40
+ }
41
+
42
+ // Device health is sampled locally and is the only thing ReMCP stores about the machine
43
+ // beyond its name, platform and last-seen time. No process list, no file names.
44
+ function deviceMetrics(extra = {}) {
45
+ const load = os.loadavg?.()[0] ?? 0;
46
+ const totalMem = os.totalmem();
47
+ const freeMem = os.freemem();
48
+ return {
49
+ uptimeSeconds: Math.round(os.uptime()),
50
+ agentUptimeSeconds: Math.round(process.uptime()),
51
+ rssBytes: process.memoryUsage().rss,
52
+ load1: Number.isFinite(load) ? Number(load.toFixed(2)) : 0,
53
+ cpuCount: os.cpus?.().length ?? 0,
54
+ freeMemoryRatio: totalMem > 0 ? Number((freeMem / totalMem).toFixed(3)) : 0,
55
+ agentVersion: VERSION,
56
+ ...extra,
57
+ };
22
58
  }
23
59
 
24
60
  export async function runAgent(options) {
@@ -28,25 +64,137 @@ export async function runAgent(options) {
28
64
  const deviceName = String(options.deviceName || os.hostname());
29
65
  if (!serverUrl || !deviceToken || !deviceId) throw new Error('serverUrl, deviceToken and deviceId are required');
30
66
  const agentUrl = serverUrl.replace(/^http/, 'ws') + '/agent';
31
- const reconnectMs = Number(options.reconnectMs || 3000);
67
+ const callTimeoutMs = Math.max(5_000, Number(options.rpcTimeoutMs || 120000) - CALL_TIMEOUT_MARGIN_MS);
68
+ const telemetryEnabled = options.telemetryEnabled !== false;
69
+ const persistState = typeof options.persistState === 'function' ? options.persistState : () => {};
32
70
  let stopping = false;
33
71
  let activeSocket;
72
+ let reconnects = 0;
73
+ let pendingRequests = 0;
74
+ let runtimeVersion = 'unknown';
75
+ let runtimeRestarts = 0;
76
+ let runtimeDown = false;
77
+ const telemetryQueue = [];
78
+ let telemetryTimer = null;
79
+ const runtimeEntry = localRuntimeEntry(options.runtime);
34
80
 
35
- const mcp = new Client({ name: 'remcp-agent', version: VERSION });
36
- const transport = new StdioClientTransport({ command: process.execPath, args: [desktopCommanderEntry()] });
37
- await mcp.connect(transport);
38
- console.log('Local device runtime ready');
81
+ // --- local runtime supervision ------------------------------------------------------
82
+ // If the runtime dies (a bad shell, a broken pipe, an OOM) the agent used to stay
83
+ // "online" forever and every later call failed with an opaque "Not connected". Now the
84
+ // transport is watched and the runtime is restarted with backoff.
85
+ let mcp = null;
86
+ let transport = null;
87
+ let runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
88
+
89
+ function runtimeEnv() {
90
+ // The SDK's stdio transport does not inherit the environment by default; spreading
91
+ // process.env here is what gives the runtime its REMCP_RUNTIME_* configuration, PATH,
92
+ // HOME and the telemetry opt-out.
93
+ const env = { ...process.env };
94
+ if (!telemetryEnabled) env.REMCP_RUNTIME_DISABLE_TELEMETRY = '1';
95
+ return env;
96
+ }
97
+
98
+ async function startRuntime() {
99
+ if (stopping) return;
100
+ runtimeDown = false;
101
+ const client = new Client({ name: 'remcp-agent', version: VERSION });
102
+ const stdio = new StdioClientTransport({ command: process.execPath, args: [runtimeEntry], env: runtimeEnv() });
103
+ mcp = client;
104
+ transport = stdio;
105
+ client.fallbackNotificationHandler = async notification => {
106
+ if (!telemetryEnabled) return;
107
+ if (notification?.method !== 'notifications/remcp/telemetry') return;
108
+ const events = Array.isArray(notification.params?.events) ? notification.params.events : [];
109
+ if (!events.length) return;
110
+ if (notification.params?.runtimeVersion) runtimeVersion = String(notification.params.runtimeVersion);
111
+ for (const event of events) {
112
+ if (telemetryQueue.length >= TELEMETRY_QUEUE_LIMIT) telemetryQueue.shift();
113
+ telemetryQueue.push(event);
114
+ }
115
+ };
116
+ stdio.onclose = () => handleRuntimeExit('closed');
117
+ stdio.onerror = error => console.error(`ReMCP local runtime error: ${error instanceof Error ? error.message : String(error)}`);
118
+ try {
119
+ await client.connect(stdio);
120
+ runtimeVersion = client.getServerVersion()?.version || runtimeVersion;
121
+ runtimeRestarts += 1;
122
+ runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
123
+ console.log(`ReMCP local runtime ready (${runtimeVersion})`);
124
+ send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: false }) });
125
+ if (runtimeRestarts > 1) queueEvent({ event: 'runtime_restart', at: Date.now(), count: runtimeRestarts, success: true });
126
+ } catch (error) {
127
+ console.error(`ReMCP local runtime failed to start: ${error instanceof Error ? error.message : String(error)}`);
128
+ handleRuntimeExit('failed');
129
+ }
130
+ }
131
+
132
+ function handleRuntimeExit(reason) {
133
+ if (stopping || runtimeDown) return;
134
+ runtimeDown = true;
135
+ const delay = jitter(runtimeRestartDelay);
136
+ runtimeRestartDelay = Math.min(RUNTIME_RESTART_MAX_MS, runtimeRestartDelay * 2);
137
+ console.error(`ReMCP local runtime ${reason}; restarting in ${delay}ms`);
138
+ queueEvent({ event: 'runtime_down', at: Date.now(), reason: reason.slice(0, 24) });
139
+ send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: true }) });
140
+ setTimeout(() => { void startRuntime(); }, delay).unref?.();
141
+ }
142
+
143
+ // --- relay connection ---------------------------------------------------------------
144
+ function send(message) {
145
+ if (activeSocket?.readyState === 1) {
146
+ activeSocket.send(JSON.stringify(message));
147
+ return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ function flushTelemetry() {
153
+ if (!telemetryEnabled || !telemetryQueue.length) return;
154
+ const batch = telemetryQueue.splice(0, TELEMETRY_BATCH_LIMIT);
155
+ if (!send({ type: 'telemetry', runtimeVersion, agentVersion: VERSION, events: batch })) telemetryQueue.unshift(...batch);
156
+ }
157
+
158
+ function queueEvent(event) {
159
+ if (!telemetryEnabled) return;
160
+ if (telemetryQueue.length >= TELEMETRY_QUEUE_LIMIT) telemetryQueue.shift();
161
+ telemetryQueue.push(event);
162
+ }
163
+
164
+ function reportInstallOnce() {
165
+ if (!telemetryEnabled || options.installReported === true) return;
166
+ if (send({
167
+ type: 'install',
168
+ agentVersion: VERSION,
169
+ runtimeVersion,
170
+ node: process.versions.node,
171
+ platform: process.platform,
172
+ arch: process.arch,
173
+ installSpec: String(options.installSpec || ''),
174
+ })) {
175
+ persistState({ installReported: true });
176
+ console.log('ReMCP reported this installation to your own workspace (disable with `remcp telemetry off`).');
177
+ }
178
+ }
39
179
 
40
180
  async function respond(ws, message) {
181
+ pendingRequests += 1;
41
182
  try {
42
183
  let result;
43
- if (message.method === 'tools/list') result = await mcp.listTools();
44
- else if (message.method === 'tools/call') result = await mcp.callTool(message.params);
45
- else if (message.method === 'ping') result = { ok: true, hostname: os.hostname(), platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(os.uptime()) };
46
- else throw new Error(`Unsupported relay method: ${message.method}`);
184
+ if (message.method === 'ping') {
185
+ result = { ok: true, hostname: os.hostname(), platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(os.uptime()), agentVersion: VERSION, runtimeVersion, runtimeRestarts };
186
+ } else if (!runtimeDown && mcp) {
187
+ if (message.method === 'tools/list') result = await mcp.listTools(undefined, { timeout: callTimeoutMs });
188
+ else if (message.method === 'tools/call') result = await mcp.callTool(message.params, undefined, { timeout: callTimeoutMs });
189
+ else throw new Error(`Unsupported relay method: ${message.method}`);
190
+ } else {
191
+ throw new Error('The ReMCP local runtime is restarting. Retry in a few seconds.');
192
+ }
47
193
  ws.send(JSON.stringify({ type: 'response', id: message.id, result }));
48
194
  } catch (error) {
49
195
  ws.send(JSON.stringify({ type: 'response', id: message.id, error: { message: error instanceof Error ? error.message : String(error) } }));
196
+ } finally {
197
+ pendingRequests = Math.max(0, pendingRequests - 1);
50
198
  }
51
199
  }
52
200
 
@@ -55,36 +203,67 @@ export async function runAgent(options) {
55
203
  const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
56
204
  activeSocket = ws;
57
205
  ws.on('open', () => {
58
- ws.send(JSON.stringify({ type: 'hello', deviceId, deviceName, hostname: os.hostname(), platform: process.platform, arch: process.arch, agentVersion: VERSION }));
59
- console.log(`Connected to ${agentUrl} as ${deviceName} (${deviceId})`);
206
+ reconnects = 0;
207
+ ws.send(JSON.stringify({
208
+ type: 'hello',
209
+ deviceId,
210
+ deviceName,
211
+ hostname: os.hostname(),
212
+ platform: process.platform,
213
+ arch: process.arch,
214
+ agentVersion: VERSION,
215
+ runtimeVersion,
216
+ telemetryEnabled,
217
+ reconnects,
218
+ }));
219
+ console.log(`Connected to ${serverUrl} as ${deviceName}`);
220
+ send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown }) });
221
+ reportInstallOnce();
222
+ flushTelemetry();
60
223
  });
61
224
  ws.on('message', raw => {
62
225
  let message;
63
226
  try { message = JSON.parse(raw.toString()); } catch { return; }
64
227
  if (message?.type === 'request') void respond(ws, message);
65
228
  });
66
- ws.on('close', () => { if (!stopping) setTimeout(connect, reconnectMs); });
229
+ ws.on('close', code => {
230
+ if (stopping) return;
231
+ if (code === 1008) {
232
+ // The relay closes with 1008 when the device was revoked. Retrying forever would
233
+ // hide that from the person at the computer.
234
+ console.error('ReMCP access for this device was revoked. Pair the machine again from the ReMCP workspace: remcp connect --server <url> --code <code> --install');
235
+ return;
236
+ }
237
+ reconnects += 1;
238
+ const delay = jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
239
+ setTimeout(connect, delay);
240
+ });
67
241
  ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
68
242
  }
69
243
 
244
+ telemetryTimer = setInterval(() => {
245
+ if (activeSocket?.readyState === 1) {
246
+ send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown, queueDepth: telemetryQueue.length }) });
247
+ flushTelemetry();
248
+ }
249
+ }, METRICS_INTERVAL_MS);
250
+ telemetryTimer.unref?.();
251
+ const telemetryFlushTimer = setInterval(flushTelemetry, TELEMETRY_SEND_INTERVAL_MS);
252
+ telemetryFlushTimer.unref?.();
253
+
70
254
  async function stop() {
71
255
  if (stopping) return;
72
256
  stopping = true;
257
+ if (telemetryTimer) clearInterval(telemetryTimer);
258
+ clearInterval(telemetryFlushTimer);
73
259
  try { activeSocket?.close(); } catch {}
74
- try { await mcp.close(); } catch {}
75
- try { await transport.close(); } catch {}
260
+ try { await mcp?.close(); } catch {}
261
+ try { await transport?.close(); } catch {}
76
262
  }
263
+
77
264
  process.once('SIGINT', () => void stop().finally(() => process.exit(0)));
78
265
  process.once('SIGTERM', () => void stop().finally(() => process.exit(0)));
266
+ await startRuntime();
79
267
  connect();
80
- return { stop };
81
- }
82
-
83
- if (import.meta.url === `file://${process.argv[1]}` && process.env.REMCP_SERVER_URL) {
84
- await runAgent({
85
- serverUrl: process.env.REMCP_SERVER_URL,
86
- deviceToken: process.env.REMCP_DEVICE_TOKEN,
87
- deviceId: process.env.REMCP_DEVICE_ID,
88
- deviceName: process.env.REMCP_DEVICE_NAME,
89
- });
268
+ return { stop, runtimeVersion: () => runtimeVersion, runtimeRestarts: () => runtimeRestarts, isRuntimeDown: () => runtimeDown };
90
269
  }