@remcp/remcp 0.2.6 → 0.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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",
package/src/agent.mjs CHANGED
@@ -12,11 +12,16 @@ import { VERSION } from './version.mjs';
12
12
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
13
13
  const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
14
14
  const UPDATE_CHECK_TIMEOUT_MS = 5000;
15
+ // A version that failed to install is retried after this cooldown instead of on every reconnect.
16
+ const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
15
17
  const METRICS_INTERVAL_MS = 60_000;
16
18
  const TELEMETRY_QUEUE_LIMIT = 500;
17
19
  const TELEMETRY_BATCH_LIMIT = 100;
18
20
  const TELEMETRY_SEND_INTERVAL_MS = 5_000;
19
21
  const RECONNECT_BASE_MS = 2_000;
22
+ // Must stay above the runtime's own output ceiling (8 MiB), otherwise a large but legal tool result
23
+ // closes the stdio connection and restarts the runtime mid-call.
24
+ const RUNTIME_STDIO_BUFFER_BYTES = 24 * 1024 * 1024;
20
25
  const RECONNECT_MAX_MS = 60_000;
21
26
  const RUNTIME_RESTART_BASE_MS = 1_000;
22
27
  const RUNTIME_RESTART_MAX_MS = 30_000;
@@ -38,8 +43,10 @@ function localRuntimeEntry(runtimeValue) {
38
43
  }
39
44
 
40
45
  function parseVersion(value) {
41
- const match = String(value || '').match(/(\d+)\.(\d+)\.(\d+)/);
42
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
46
+ // Prerelease and build metadata are kept, because comparing only the numeric core made
47
+ // 1.0.0 look newer than 1.0.0-beta.2 and left a machine stuck on the prerelease forever.
48
+ const match = String(value || '').match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
49
+ return match ? { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] || '' } : null;
43
50
  }
44
51
 
45
52
  function isNewer(candidate, current) {
@@ -47,8 +54,27 @@ function isNewer(candidate, current) {
47
54
  const b = parseVersion(current);
48
55
  if (!a || !b) return false;
49
56
  for (let index = 0; index < 3; index += 1) {
50
- if (a[index] > b[index]) return true;
51
- if (a[index] < b[index]) return false;
57
+ if (a.parts[index] > b.parts[index]) return true;
58
+ if (a.parts[index] < b.parts[index]) return false;
59
+ }
60
+ // Same numeric core: a release is newer than a prerelease, and two prereleases compare by
61
+ // identifier (numeric identifiers order numerically, as semver requires).
62
+ if (!a.prerelease && b.prerelease) return true;
63
+ if (a.prerelease && !b.prerelease) return false;
64
+ if (!a.prerelease && !b.prerelease) return false;
65
+ const left = a.prerelease.split('.');
66
+ const right = b.prerelease.split('.');
67
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
68
+ const one = left[index];
69
+ const two = right[index];
70
+ if (one === undefined) return false;
71
+ if (two === undefined) return true;
72
+ if (one === two) continue;
73
+ const oneNumeric = /^\d+$/.test(one);
74
+ const twoNumeric = /^\d+$/.test(two);
75
+ if (oneNumeric && twoNumeric) return Number(one) > Number(two);
76
+ if (oneNumeric !== twoNumeric) return oneNumeric;
77
+ return one > two;
52
78
  }
53
79
  return false;
54
80
  }
@@ -95,6 +121,8 @@ export async function runAgent(options) {
95
121
  const telemetryEnabled = options.telemetryEnabled !== false;
96
122
  const persistState = typeof options.persistState === 'function' ? options.persistState : () => {};
97
123
  let stopping = false;
124
+ let revoked = false;
125
+ const inFlight = new Map();
98
126
  let activeSocket;
99
127
  let reconnects = 0;
100
128
  let pendingRequests = 0;
@@ -126,7 +154,7 @@ export async function runAgent(options) {
126
154
  if (stopping) return;
127
155
  runtimeDown = false;
128
156
  const client = new Client({ name: 'remcp-agent', version: VERSION });
129
- const stdio = new StdioClientTransport({ command: process.execPath, args: [runtimeEntry], env: runtimeEnv() });
157
+ const stdio = new StdioClientTransport({ command: process.execPath, args: [runtimeEntry], env: runtimeEnv(), maxBufferSize: RUNTIME_STDIO_BUFFER_BYTES });
130
158
  mcp = client;
131
159
  transport = stdio;
132
160
  client.fallbackNotificationHandler = async notification => {
@@ -206,21 +234,28 @@ export async function runAgent(options) {
206
234
 
207
235
  async function respond(ws, message) {
208
236
  pendingRequests += 1;
237
+ // The relay forwards a cancel when the MCP client goes away. Without it a cancelled tool call
238
+ // kept running on the machine (a delete still deleted), because nothing told the runtime.
239
+ const controller = new AbortController();
240
+ inFlight.set(message.id, controller);
209
241
  try {
210
242
  let result;
211
243
  if (message.method === 'ping') {
212
244
  result = { ok: true, hostname: os.hostname(), platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(os.uptime()), agentVersion: VERSION, runtimeVersion, runtimeRestarts };
213
245
  } else if (!runtimeDown && mcp) {
214
- if (message.method === 'tools/list') result = await mcp.listTools(undefined, { timeout: callTimeoutMs });
215
- else if (message.method === 'tools/call') result = await mcp.callTool(message.params, undefined, { timeout: callTimeoutMs });
246
+ const options = { timeout: callTimeoutMs, signal: controller.signal };
247
+ if (message.method === 'tools/list') result = await mcp.listTools(undefined, options);
248
+ else if (message.method === 'tools/call') result = await mcp.callTool(message.params, undefined, options);
216
249
  else throw new Error(`Unsupported relay method: ${message.method}`);
217
250
  } else {
218
251
  throw new Error('The ReMCP local runtime is restarting. Retry in a few seconds.');
219
252
  }
220
253
  ws.send(JSON.stringify({ type: 'response', id: message.id, result }));
221
254
  } catch (error) {
222
- ws.send(JSON.stringify({ type: 'response', id: message.id, error: { message: error instanceof Error ? error.message : String(error) } }));
255
+ const cancelled = controller.signal.aborted;
256
+ ws.send(JSON.stringify({ type: 'response', id: message.id, error: { message: cancelled ? 'Cancelled: the client stopped waiting for this call.' : error instanceof Error ? error.message : String(error) } }));
223
257
  } finally {
258
+ inFlight.delete(message.id);
224
259
  pendingRequests = Math.max(0, pendingRequests - 1);
225
260
  }
226
261
  }
@@ -228,6 +263,13 @@ export async function runAgent(options) {
228
263
  function connect() {
229
264
  if (stopping) return;
230
265
  const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
266
+ // A revoked device is refused during the handshake with a 401 and this header, because a bare
267
+ // rejection looked like a network problem (close 1006) and the agent retried it forever.
268
+ ws.on('unexpected-response', (_request, response) => {
269
+ if (String(response.headers['x-remcp-revoked'] || '') === '1') revoked = true;
270
+ console.error(`ReMCP relay refused the connection (HTTP ${response.statusCode})${revoked ? ': this device was revoked' : ''}.`);
271
+ response.resume();
272
+ });
231
273
  activeSocket = ws;
232
274
  ws.on('open', () => {
233
275
  reconnects = 0;
@@ -253,15 +295,26 @@ export async function runAgent(options) {
253
295
  let message;
254
296
  try { message = JSON.parse(raw.toString()); } catch { return; }
255
297
  if (message?.type === 'request') void respond(ws, message);
298
+ if (message?.type === 'cancel' && message.id) {
299
+ const controller = inFlight.get(message.id);
300
+ if (controller) controller.abort();
301
+ }
256
302
  });
257
303
  ws.on('close', code => {
258
304
  if (stopping) return;
259
- if (code === 1008) {
305
+ if (code === 1008 || revoked) {
260
306
  // The relay closes with 1008 when the device was revoked. Retrying forever would
261
307
  // hide that from the person at the computer.
262
308
  console.error('ReMCP access for this device was revoked. Pair the machine again from the ReMCP workspace: remcp connect --server <url> --code <code> --install');
263
309
  return;
264
310
  }
311
+ if (code === 1012) {
312
+ // 1012 ('service restart') is what the relay sends when another agent process took over
313
+ // this device. Reconnecting immediately produced two agents evicting each other in a loop,
314
+ // so back off and let the surviving process keep the connection.
315
+ console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (systemd service or `remcp start`).');
316
+ return;
317
+ }
265
318
  reconnects += 1;
266
319
  const delay = jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
267
320
  setTimeout(connect, delay);
@@ -272,8 +325,16 @@ export async function runAgent(options) {
272
325
  // Auto-update: the server publishes the versions an agent should be running. A newer
273
326
  // release is installed in the background and the service restart picks it up; a failed
274
327
  // or skipped update leaves the current version running, so an old agent keeps working.
328
+ //
329
+ // The update must be idempotent across reconnects: a flapping relay used to start one
330
+ // `npm install -g` per reconnect, so a machine could run several installers (and service
331
+ // restarts) at once. One attempt per advertised version, and never two at the same time.
332
+ let updateInFlight = false;
333
+ let lastAttemptedVersion = '';
334
+ let lastAttemptAt = 0;
275
335
  async function checkForUpdate() {
276
336
  if (options.autoUpdate === false) return;
337
+ if (updateInFlight) return;
277
338
  try {
278
339
  const response = await fetch(`${serverUrl}/api/agent/version`, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS) });
279
340
  if (!response.ok) return;
@@ -283,18 +344,27 @@ export async function runAgent(options) {
283
344
  console.error(`ReMCP ${VERSION} is older than the minimum supported agent ${minimum}; update with: remcp update`);
284
345
  }
285
346
  if (!isNewer(advertised.cli, VERSION)) return;
286
- queueEvent({ event: 'agent_update', at: Date.now(), reason: String(advertised.cli).slice(0, 32), success: true });
347
+ const target = String(advertised.cli);
348
+ queueEvent({ event: 'agent_update', at: Date.now(), reason: target.slice(0, 32), success: true });
287
349
  const cli = globalCliEntry();
288
350
  if (!cli || !existsSync(cli)) {
289
- console.error(`ReMCP ${advertised.cli} is available; run: remcp update`);
351
+ console.error(`ReMCP ${target} is available; run: remcp update`);
290
352
  return;
291
353
  }
292
- console.log(`Updating ReMCP to ${advertised.cli}${advertised.runtime ? ` with ${advertised.runtime}` : ''}…`);
293
- const child = spawn(process.execPath, [cli, 'update', ...(advertised.runtime ? ['--runtime', advertised.runtime] : [])], {
354
+ // A version that already failed to install is retried only after a cooldown, so a broken
355
+ // release cannot turn into an install loop.
356
+ if (target === lastAttemptedVersion && Date.now() - lastAttemptAt < UPDATE_RETRY_COOLDOWN_MS) return;
357
+ lastAttemptedVersion = target;
358
+ lastAttemptAt = Date.now();
359
+ updateInFlight = true;
360
+ console.log(`Updating ReMCP to ${target}${advertised.runtime ? ` with ${advertised.runtime}` : ''}…`);
361
+ const child = spawn(process.execPath, [cli, 'update', '--trust-runtime', ...(advertised.runtime ? ['--runtime', advertised.runtime] : [])], {
294
362
  detached: true,
295
363
  stdio: 'ignore',
296
364
  env: { ...process.env },
297
365
  });
366
+ child.on('exit', () => { updateInFlight = false; });
367
+ child.on('error', () => { updateInFlight = false; });
298
368
  child.unref();
299
369
  } catch {
300
370
  // Offline, DNS failure, older server without the endpoint: keep running as-is.
package/src/cli.mjs CHANGED
@@ -53,7 +53,9 @@ function loadConfig(required = true) {
53
53
  throw new Error(`ReMCP is not paired. Generate a pairing command at ${officialOrigin}/app/connect`);
54
54
  }
55
55
  const value = JSON.parse(fs.readFileSync(configFile, 'utf8'));
56
- value.runtime = normalizeRuntime(value.runtime);
56
+ // A configuration that only carries preferences (for example after `remcp auto-update off`
57
+ // before pairing) has no runtime yet; it must not fail as if it were corrupt.
58
+ if (value.runtime !== undefined) value.runtime = normalizeRuntime(value.runtime);
57
59
  return value;
58
60
  }
59
61
 
@@ -331,17 +333,29 @@ export async function main(argv = process.argv.slice(2)) {
331
333
  // The server advertises which runtime version it expects; an agent that is updating
332
334
  // itself passes it through so client and runtime move together.
333
335
  const requested = typeof flags.runtime === 'string' ? flags.runtime.trim() : '';
334
- if (requested && (!/^@?[a-z0-9._-]+(\/[a-z0-9._-]+)?@\S+$/i.test(requested) || requested.includes(' '))) {
335
- throw new Error('--runtime must look like @scope/package@1.2.3');
336
+ // Whatever the server advertises is installed globally, so it is validated exactly like the
337
+ // metadata from a pairing response: same package as the configured runtime, a spec that
338
+ // parses, and an explicit --trust-runtime before a custom server may change it.
339
+ let runtimeSpec = cfg.runtime.packageSpec;
340
+ if (requested) {
341
+ const parsed = requested.match(/^(@?[a-z0-9._-]+(?:\/[a-z0-9._-]+)?)@(\S+)$/i);
342
+ if (!parsed) throw new Error('--runtime must look like @scope/package@1.2.3');
343
+ if (parsed[1] !== cfg.runtime.packageName) {
344
+ throw new Error(`--runtime must stay on ${cfg.runtime.packageName}; refusing to install ${parsed[1]}`);
345
+ }
346
+ if (!flags['trust-runtime'] && !flags['yes']) {
347
+ throw new Error('Installing a runtime version from the server requires --trust-runtime.');
348
+ }
349
+ runtimeSpec = normalizeRuntime({ kind: 'npm', packageName: parsed[1], packageSpec: requested, entry: cfg.runtime.entry }).packageSpec;
336
350
  }
337
- const runtimeSpec = requested || cfg.runtime.packageSpec;
338
351
  if (flags.check) {
339
352
  console.log(JSON.stringify({ current: VERSION, runtime: cfg.runtime.packageSpec, available: `${PACKAGE_NAME}@latest` }, null, 2));
340
353
  return;
341
354
  }
342
355
  console.log(`Updating ReMCP to the latest published version (${runtimeSpec})…`);
343
356
  npmGlobalInstall(`${PACKAGE_NAME}@latest`, runtimeSpec);
344
- if (requested) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: requested } });
357
+ // Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
358
+ if (requested && runtimeSpec !== cfg.runtime.packageSpec) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: runtimeSpec } });
345
359
  restartPersistentServiceIfInstalled();
346
360
  console.log('ReMCP updated. Run `remcp --version` or `remcp status` to verify.');
347
361
  return;