@remcp/remcp 0.2.6 → 0.2.8
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 +101 -14
- package/src/cli.mjs +19 -5
package/package.json
CHANGED
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
|
-
|
|
42
|
-
|
|
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,12 +54,46 @@ 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
|
}
|
|
55
81
|
|
|
82
|
+
// What the agent should install, if anything. The client version alone is not enough: a machine
|
|
83
|
+
// that already runs the newest client but an older local runtime would otherwise never catch up,
|
|
84
|
+
// because its runtime is what executes the tools.
|
|
85
|
+
export function updateDecision({ advertised, cliVersion, runtimeVersion }) {
|
|
86
|
+
const cliSpec = String(advertised?.cli || '');
|
|
87
|
+
const runtimeSpec = String(advertised?.runtime || '');
|
|
88
|
+
const installedRuntime = String(runtimeVersion || '');
|
|
89
|
+
const runtimeKnown = Boolean(installedRuntime) && !/^unknown$/i.test(installedRuntime);
|
|
90
|
+
if (isNewer(cliSpec, cliVersion)) return { needed: true, target: cliSpec, runtime: runtimeSpec, reason: 'client' };
|
|
91
|
+
if (runtimeKnown && runtimeSpec && isNewer(runtimeSpec, installedRuntime)) {
|
|
92
|
+
return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime' };
|
|
93
|
+
}
|
|
94
|
+
return { needed: false, target: cliSpec, runtime: runtimeSpec, reason: 'current' };
|
|
95
|
+
}
|
|
96
|
+
|
|
56
97
|
function globalCliEntry() {
|
|
57
98
|
const prefix = spawnSync(npmCommand, ['prefix', '--global'], { encoding: 'utf8' });
|
|
58
99
|
if (prefix.error || prefix.status !== 0) return null;
|
|
@@ -95,6 +136,8 @@ export async function runAgent(options) {
|
|
|
95
136
|
const telemetryEnabled = options.telemetryEnabled !== false;
|
|
96
137
|
const persistState = typeof options.persistState === 'function' ? options.persistState : () => {};
|
|
97
138
|
let stopping = false;
|
|
139
|
+
let revoked = false;
|
|
140
|
+
const inFlight = new Map();
|
|
98
141
|
let activeSocket;
|
|
99
142
|
let reconnects = 0;
|
|
100
143
|
let pendingRequests = 0;
|
|
@@ -126,7 +169,7 @@ export async function runAgent(options) {
|
|
|
126
169
|
if (stopping) return;
|
|
127
170
|
runtimeDown = false;
|
|
128
171
|
const client = new Client({ name: 'remcp-agent', version: VERSION });
|
|
129
|
-
const stdio = new StdioClientTransport({ command: process.execPath, args: [runtimeEntry], env: runtimeEnv() });
|
|
172
|
+
const stdio = new StdioClientTransport({ command: process.execPath, args: [runtimeEntry], env: runtimeEnv(), maxBufferSize: RUNTIME_STDIO_BUFFER_BYTES });
|
|
130
173
|
mcp = client;
|
|
131
174
|
transport = stdio;
|
|
132
175
|
client.fallbackNotificationHandler = async notification => {
|
|
@@ -206,21 +249,28 @@ export async function runAgent(options) {
|
|
|
206
249
|
|
|
207
250
|
async function respond(ws, message) {
|
|
208
251
|
pendingRequests += 1;
|
|
252
|
+
// The relay forwards a cancel when the MCP client goes away. Without it a cancelled tool call
|
|
253
|
+
// kept running on the machine (a delete still deleted), because nothing told the runtime.
|
|
254
|
+
const controller = new AbortController();
|
|
255
|
+
inFlight.set(message.id, controller);
|
|
209
256
|
try {
|
|
210
257
|
let result;
|
|
211
258
|
if (message.method === 'ping') {
|
|
212
259
|
result = { ok: true, hostname: os.hostname(), platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(os.uptime()), agentVersion: VERSION, runtimeVersion, runtimeRestarts };
|
|
213
260
|
} else if (!runtimeDown && mcp) {
|
|
214
|
-
|
|
215
|
-
|
|
261
|
+
const options = { timeout: callTimeoutMs, signal: controller.signal };
|
|
262
|
+
if (message.method === 'tools/list') result = await mcp.listTools(undefined, options);
|
|
263
|
+
else if (message.method === 'tools/call') result = await mcp.callTool(message.params, undefined, options);
|
|
216
264
|
else throw new Error(`Unsupported relay method: ${message.method}`);
|
|
217
265
|
} else {
|
|
218
266
|
throw new Error('The ReMCP local runtime is restarting. Retry in a few seconds.');
|
|
219
267
|
}
|
|
220
268
|
ws.send(JSON.stringify({ type: 'response', id: message.id, result }));
|
|
221
269
|
} catch (error) {
|
|
222
|
-
|
|
270
|
+
const cancelled = controller.signal.aborted;
|
|
271
|
+
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
272
|
} finally {
|
|
273
|
+
inFlight.delete(message.id);
|
|
224
274
|
pendingRequests = Math.max(0, pendingRequests - 1);
|
|
225
275
|
}
|
|
226
276
|
}
|
|
@@ -228,6 +278,13 @@ export async function runAgent(options) {
|
|
|
228
278
|
function connect() {
|
|
229
279
|
if (stopping) return;
|
|
230
280
|
const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
|
|
281
|
+
// A revoked device is refused during the handshake with a 401 and this header, because a bare
|
|
282
|
+
// rejection looked like a network problem (close 1006) and the agent retried it forever.
|
|
283
|
+
ws.on('unexpected-response', (_request, response) => {
|
|
284
|
+
if (String(response.headers['x-remcp-revoked'] || '') === '1') revoked = true;
|
|
285
|
+
console.error(`ReMCP relay refused the connection (HTTP ${response.statusCode})${revoked ? ': this device was revoked' : ''}.`);
|
|
286
|
+
response.resume();
|
|
287
|
+
});
|
|
231
288
|
activeSocket = ws;
|
|
232
289
|
ws.on('open', () => {
|
|
233
290
|
reconnects = 0;
|
|
@@ -253,15 +310,26 @@ export async function runAgent(options) {
|
|
|
253
310
|
let message;
|
|
254
311
|
try { message = JSON.parse(raw.toString()); } catch { return; }
|
|
255
312
|
if (message?.type === 'request') void respond(ws, message);
|
|
313
|
+
if (message?.type === 'cancel' && message.id) {
|
|
314
|
+
const controller = inFlight.get(message.id);
|
|
315
|
+
if (controller) controller.abort();
|
|
316
|
+
}
|
|
256
317
|
});
|
|
257
318
|
ws.on('close', code => {
|
|
258
319
|
if (stopping) return;
|
|
259
|
-
if (code === 1008) {
|
|
320
|
+
if (code === 1008 || revoked) {
|
|
260
321
|
// The relay closes with 1008 when the device was revoked. Retrying forever would
|
|
261
322
|
// hide that from the person at the computer.
|
|
262
323
|
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
324
|
return;
|
|
264
325
|
}
|
|
326
|
+
if (code === 1012) {
|
|
327
|
+
// 1012 ('service restart') is what the relay sends when another agent process took over
|
|
328
|
+
// this device. Reconnecting immediately produced two agents evicting each other in a loop,
|
|
329
|
+
// so back off and let the surviving process keep the connection.
|
|
330
|
+
console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (systemd service or `remcp start`).');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
265
333
|
reconnects += 1;
|
|
266
334
|
const delay = jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
|
|
267
335
|
setTimeout(connect, delay);
|
|
@@ -272,8 +340,16 @@ export async function runAgent(options) {
|
|
|
272
340
|
// Auto-update: the server publishes the versions an agent should be running. A newer
|
|
273
341
|
// release is installed in the background and the service restart picks it up; a failed
|
|
274
342
|
// or skipped update leaves the current version running, so an old agent keeps working.
|
|
343
|
+
//
|
|
344
|
+
// The update must be idempotent across reconnects: a flapping relay used to start one
|
|
345
|
+
// `npm install -g` per reconnect, so a machine could run several installers (and service
|
|
346
|
+
// restarts) at once. One attempt per advertised version, and never two at the same time.
|
|
347
|
+
let updateInFlight = false;
|
|
348
|
+
let lastAttemptedVersion = '';
|
|
349
|
+
let lastAttemptAt = 0;
|
|
275
350
|
async function checkForUpdate() {
|
|
276
351
|
if (options.autoUpdate === false) return;
|
|
352
|
+
if (updateInFlight) return;
|
|
277
353
|
try {
|
|
278
354
|
const response = await fetch(`${serverUrl}/api/agent/version`, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS) });
|
|
279
355
|
if (!response.ok) return;
|
|
@@ -282,19 +358,30 @@ export async function runAgent(options) {
|
|
|
282
358
|
if (minimum && isNewer(minimum, VERSION)) {
|
|
283
359
|
console.error(`ReMCP ${VERSION} is older than the minimum supported agent ${minimum}; update with: remcp update`);
|
|
284
360
|
}
|
|
285
|
-
|
|
286
|
-
|
|
361
|
+
const decision = updateDecision({ advertised, cliVersion: VERSION, runtimeVersion });
|
|
362
|
+
if (!decision.needed) return;
|
|
363
|
+
const target = decision.target;
|
|
364
|
+
queueEvent({ event: 'agent_update', at: Date.now(), reason: `${decision.reason}:${target}`.slice(0, 32), success: true });
|
|
287
365
|
const cli = globalCliEntry();
|
|
288
366
|
if (!cli || !existsSync(cli)) {
|
|
289
|
-
console.error(`ReMCP ${
|
|
367
|
+
console.error(`ReMCP ${target} is available; run: remcp update`);
|
|
290
368
|
return;
|
|
291
369
|
}
|
|
292
|
-
|
|
293
|
-
|
|
370
|
+
// A version that already failed to install is retried only after a cooldown, so a broken
|
|
371
|
+
// release cannot turn into an install loop.
|
|
372
|
+
const attemptKey = `${target}|${decision.runtime}`;
|
|
373
|
+
if (attemptKey === lastAttemptedVersion && Date.now() - lastAttemptAt < UPDATE_RETRY_COOLDOWN_MS) return;
|
|
374
|
+
lastAttemptedVersion = attemptKey;
|
|
375
|
+
lastAttemptAt = Date.now();
|
|
376
|
+
updateInFlight = true;
|
|
377
|
+
console.log(`Updating ReMCP to ${target}${decision.runtime ? ` with ${decision.runtime}` : ''} (${decision.reason})…`);
|
|
378
|
+
const child = spawn(process.execPath, [cli, 'update', '--trust-runtime', ...(decision.runtime ? ['--runtime', decision.runtime] : [])], {
|
|
294
379
|
detached: true,
|
|
295
380
|
stdio: 'ignore',
|
|
296
381
|
env: { ...process.env },
|
|
297
382
|
});
|
|
383
|
+
child.on('exit', () => { updateInFlight = false; });
|
|
384
|
+
child.on('error', () => { updateInFlight = false; });
|
|
298
385
|
child.unref();
|
|
299
386
|
} catch {
|
|
300
387
|
// 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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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;
|