@remcp/remcp 0.2.5 → 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 +1 -1
- package/src/agent.mjs +139 -6
- package/src/cli.mjs +42 -3
package/package.json
CHANGED
package/src/agent.mjs
CHANGED
|
@@ -2,7 +2,7 @@ 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 { spawnSync } from 'node:child_process';
|
|
5
|
+
import { spawn, 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';
|
|
@@ -10,11 +10,18 @@ import { normalizeRuntime } from './runtime.mjs';
|
|
|
10
10
|
import { VERSION } from './version.mjs';
|
|
11
11
|
|
|
12
12
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
13
|
+
const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
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;
|
|
13
17
|
const METRICS_INTERVAL_MS = 60_000;
|
|
14
18
|
const TELEMETRY_QUEUE_LIMIT = 500;
|
|
15
19
|
const TELEMETRY_BATCH_LIMIT = 100;
|
|
16
20
|
const TELEMETRY_SEND_INTERVAL_MS = 5_000;
|
|
17
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;
|
|
18
25
|
const RECONNECT_MAX_MS = 60_000;
|
|
19
26
|
const RUNTIME_RESTART_BASE_MS = 1_000;
|
|
20
27
|
const RUNTIME_RESTART_MAX_MS = 30_000;
|
|
@@ -35,6 +42,52 @@ function localRuntimeEntry(runtimeValue) {
|
|
|
35
42
|
return candidate;
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
function parseVersion(value) {
|
|
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;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isNewer(candidate, current) {
|
|
53
|
+
const a = parseVersion(candidate);
|
|
54
|
+
const b = parseVersion(current);
|
|
55
|
+
if (!a || !b) return false;
|
|
56
|
+
for (let index = 0; index < 3; index += 1) {
|
|
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;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function globalCliEntry() {
|
|
83
|
+
const prefix = spawnSync(npmCommand, ['prefix', '--global'], { encoding: 'utf8' });
|
|
84
|
+
if (prefix.error || prefix.status !== 0) return null;
|
|
85
|
+
const base = String(prefix.stdout || '').trim();
|
|
86
|
+
return process.platform === 'win32'
|
|
87
|
+
? path.join(base, 'remcp.cmd')
|
|
88
|
+
: path.join(base, 'bin', 'remcp');
|
|
89
|
+
}
|
|
90
|
+
|
|
38
91
|
function jitter(ms) {
|
|
39
92
|
return Math.round(ms * (0.75 + Math.random() * 0.5));
|
|
40
93
|
}
|
|
@@ -68,6 +121,8 @@ export async function runAgent(options) {
|
|
|
68
121
|
const telemetryEnabled = options.telemetryEnabled !== false;
|
|
69
122
|
const persistState = typeof options.persistState === 'function' ? options.persistState : () => {};
|
|
70
123
|
let stopping = false;
|
|
124
|
+
let revoked = false;
|
|
125
|
+
const inFlight = new Map();
|
|
71
126
|
let activeSocket;
|
|
72
127
|
let reconnects = 0;
|
|
73
128
|
let pendingRequests = 0;
|
|
@@ -99,7 +154,7 @@ export async function runAgent(options) {
|
|
|
99
154
|
if (stopping) return;
|
|
100
155
|
runtimeDown = false;
|
|
101
156
|
const client = new Client({ name: 'remcp-agent', version: VERSION });
|
|
102
|
-
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 });
|
|
103
158
|
mcp = client;
|
|
104
159
|
transport = stdio;
|
|
105
160
|
client.fallbackNotificationHandler = async notification => {
|
|
@@ -179,21 +234,28 @@ export async function runAgent(options) {
|
|
|
179
234
|
|
|
180
235
|
async function respond(ws, message) {
|
|
181
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);
|
|
182
241
|
try {
|
|
183
242
|
let result;
|
|
184
243
|
if (message.method === 'ping') {
|
|
185
244
|
result = { ok: true, hostname: os.hostname(), platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(os.uptime()), agentVersion: VERSION, runtimeVersion, runtimeRestarts };
|
|
186
245
|
} else if (!runtimeDown && mcp) {
|
|
187
|
-
|
|
188
|
-
|
|
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);
|
|
189
249
|
else throw new Error(`Unsupported relay method: ${message.method}`);
|
|
190
250
|
} else {
|
|
191
251
|
throw new Error('The ReMCP local runtime is restarting. Retry in a few seconds.');
|
|
192
252
|
}
|
|
193
253
|
ws.send(JSON.stringify({ type: 'response', id: message.id, result }));
|
|
194
254
|
} catch (error) {
|
|
195
|
-
|
|
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) } }));
|
|
196
257
|
} finally {
|
|
258
|
+
inFlight.delete(message.id);
|
|
197
259
|
pendingRequests = Math.max(0, pendingRequests - 1);
|
|
198
260
|
}
|
|
199
261
|
}
|
|
@@ -201,6 +263,13 @@ export async function runAgent(options) {
|
|
|
201
263
|
function connect() {
|
|
202
264
|
if (stopping) return;
|
|
203
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
|
+
});
|
|
204
273
|
activeSocket = ws;
|
|
205
274
|
ws.on('open', () => {
|
|
206
275
|
reconnects = 0;
|
|
@@ -220,20 +289,32 @@ export async function runAgent(options) {
|
|
|
220
289
|
send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown }) });
|
|
221
290
|
reportInstallOnce();
|
|
222
291
|
flushTelemetry();
|
|
292
|
+
void checkForUpdate();
|
|
223
293
|
});
|
|
224
294
|
ws.on('message', raw => {
|
|
225
295
|
let message;
|
|
226
296
|
try { message = JSON.parse(raw.toString()); } catch { return; }
|
|
227
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
|
+
}
|
|
228
302
|
});
|
|
229
303
|
ws.on('close', code => {
|
|
230
304
|
if (stopping) return;
|
|
231
|
-
if (code === 1008) {
|
|
305
|
+
if (code === 1008 || revoked) {
|
|
232
306
|
// The relay closes with 1008 when the device was revoked. Retrying forever would
|
|
233
307
|
// hide that from the person at the computer.
|
|
234
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');
|
|
235
309
|
return;
|
|
236
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
|
+
}
|
|
237
318
|
reconnects += 1;
|
|
238
319
|
const delay = jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
|
|
239
320
|
setTimeout(connect, delay);
|
|
@@ -241,6 +322,55 @@ export async function runAgent(options) {
|
|
|
241
322
|
ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
|
|
242
323
|
}
|
|
243
324
|
|
|
325
|
+
// Auto-update: the server publishes the versions an agent should be running. A newer
|
|
326
|
+
// release is installed in the background and the service restart picks it up; a failed
|
|
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;
|
|
335
|
+
async function checkForUpdate() {
|
|
336
|
+
if (options.autoUpdate === false) return;
|
|
337
|
+
if (updateInFlight) return;
|
|
338
|
+
try {
|
|
339
|
+
const response = await fetch(`${serverUrl}/api/agent/version`, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS) });
|
|
340
|
+
if (!response.ok) return;
|
|
341
|
+
const advertised = await response.json();
|
|
342
|
+
const minimum = advertised.minimum;
|
|
343
|
+
if (minimum && isNewer(minimum, VERSION)) {
|
|
344
|
+
console.error(`ReMCP ${VERSION} is older than the minimum supported agent ${minimum}; update with: remcp update`);
|
|
345
|
+
}
|
|
346
|
+
if (!isNewer(advertised.cli, VERSION)) return;
|
|
347
|
+
const target = String(advertised.cli);
|
|
348
|
+
queueEvent({ event: 'agent_update', at: Date.now(), reason: target.slice(0, 32), success: true });
|
|
349
|
+
const cli = globalCliEntry();
|
|
350
|
+
if (!cli || !existsSync(cli)) {
|
|
351
|
+
console.error(`ReMCP ${target} is available; run: remcp update`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
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] : [])], {
|
|
362
|
+
detached: true,
|
|
363
|
+
stdio: 'ignore',
|
|
364
|
+
env: { ...process.env },
|
|
365
|
+
});
|
|
366
|
+
child.on('exit', () => { updateInFlight = false; });
|
|
367
|
+
child.on('error', () => { updateInFlight = false; });
|
|
368
|
+
child.unref();
|
|
369
|
+
} catch {
|
|
370
|
+
// Offline, DNS failure, older server without the endpoint: keep running as-is.
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
244
374
|
telemetryTimer = setInterval(() => {
|
|
245
375
|
if (activeSocket?.readyState === 1) {
|
|
246
376
|
send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown, queueDepth: telemetryQueue.length }) });
|
|
@@ -250,12 +380,15 @@ export async function runAgent(options) {
|
|
|
250
380
|
telemetryTimer.unref?.();
|
|
251
381
|
const telemetryFlushTimer = setInterval(flushTelemetry, TELEMETRY_SEND_INTERVAL_MS);
|
|
252
382
|
telemetryFlushTimer.unref?.();
|
|
383
|
+
const updateTimer = setInterval(() => void checkForUpdate(), UPDATE_CHECK_INTERVAL_MS);
|
|
384
|
+
updateTimer.unref?.();
|
|
253
385
|
|
|
254
386
|
async function stop() {
|
|
255
387
|
if (stopping) return;
|
|
256
388
|
stopping = true;
|
|
257
389
|
if (telemetryTimer) clearInterval(telemetryTimer);
|
|
258
390
|
clearInterval(telemetryFlushTimer);
|
|
391
|
+
clearInterval(updateTimer);
|
|
259
392
|
try { activeSocket?.close(); } catch {}
|
|
260
393
|
try { await mcp?.close(); } catch {}
|
|
261
394
|
try { await transport?.close(); } catch {}
|
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
|
-
|
|
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
|
|
|
@@ -272,6 +274,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
272
274
|
const telemetry = telemetryState();
|
|
273
275
|
await runAgent({
|
|
274
276
|
...cfg,
|
|
277
|
+
autoUpdate: cfg.autoUpdate !== false,
|
|
275
278
|
telemetryEnabled: telemetry.enabled,
|
|
276
279
|
installReported: telemetry.installReported,
|
|
277
280
|
installSpec: `${PACKAGE_NAME}@${VERSION}`,
|
|
@@ -280,6 +283,18 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
280
283
|
return;
|
|
281
284
|
}
|
|
282
285
|
|
|
286
|
+
if (command === 'auto-update') {
|
|
287
|
+
const action = String(positional[0] || 'status').toLowerCase();
|
|
288
|
+
const cfg = loadConfig(false) || {};
|
|
289
|
+
if (action === 'status') {
|
|
290
|
+
console.log(JSON.stringify({ autoUpdate: cfg.autoUpdate !== false, checkIntervalHours: 6 }, null, 2));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (action === 'on' || action === 'enable') { saveConfig({ ...cfg, autoUpdate: true }); console.log('Auto-update enabled.'); return; }
|
|
294
|
+
if (action === 'off' || action === 'disable') { saveConfig({ ...cfg, autoUpdate: false }); console.log('Auto-update disabled. Run `remcp update` yourself when you want a new version.'); return; }
|
|
295
|
+
throw new Error('Usage: remcp auto-update [status|on|off]');
|
|
296
|
+
}
|
|
297
|
+
|
|
283
298
|
if (command === 'telemetry') {
|
|
284
299
|
const action = String(positional[0] || 'status').toLowerCase();
|
|
285
300
|
if (action === 'status') {
|
|
@@ -315,8 +330,32 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
315
330
|
|
|
316
331
|
if (command === 'update') {
|
|
317
332
|
const cfg = loadConfig();
|
|
318
|
-
|
|
319
|
-
|
|
333
|
+
// The server advertises which runtime version it expects; an agent that is updating
|
|
334
|
+
// itself passes it through so client and runtime move together.
|
|
335
|
+
const requested = typeof flags.runtime === 'string' ? flags.runtime.trim() : '';
|
|
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;
|
|
350
|
+
}
|
|
351
|
+
if (flags.check) {
|
|
352
|
+
console.log(JSON.stringify({ current: VERSION, runtime: cfg.runtime.packageSpec, available: `${PACKAGE_NAME}@latest` }, null, 2));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
console.log(`Updating ReMCP to the latest published version (${runtimeSpec})…`);
|
|
356
|
+
npmGlobalInstall(`${PACKAGE_NAME}@latest`, runtimeSpec);
|
|
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 } });
|
|
320
359
|
restartPersistentServiceIfInstalled();
|
|
321
360
|
console.log('ReMCP updated. Run `remcp --version` or `remcp status` to verify.');
|
|
322
361
|
return;
|