@remcp/remcp 0.2.12 → 0.2.15
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 +16 -5
- package/src/cli.mjs +104 -8
- package/src/npm.mjs +79 -0
package/package.json
CHANGED
package/src/agent.mjs
CHANGED
|
@@ -6,10 +6,13 @@ 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';
|
|
9
|
+
import { resolveNpm } from './npm.mjs';
|
|
9
10
|
import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
|
|
10
11
|
import { VERSION } from './version.mjs';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
// See src/npm.mjs: a service started by launchd or systemd has a minimal PATH, so npm is resolved
|
|
14
|
+
// from the running node instead of being looked up on PATH.
|
|
15
|
+
const npm = resolveNpm();
|
|
13
16
|
const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
14
17
|
const UPDATE_CHECK_TIMEOUT_MS = 5000;
|
|
15
18
|
// A version that failed to install is retried after this cooldown instead of on every reconnect.
|
|
@@ -30,12 +33,12 @@ const RUNTIME_RESTART_MAX_MS = 30_000;
|
|
|
30
33
|
const CALL_TIMEOUT_MARGIN_MS = 10_000;
|
|
31
34
|
|
|
32
35
|
function globalNodeModules() {
|
|
33
|
-
const result = spawnSync(
|
|
36
|
+
const result = spawnSync(npm.command, [...npm.args, 'root', '--global'], { encoding: 'utf8' });
|
|
34
37
|
if (result.error || result.status !== 0) throw new Error('Could not locate the global npm modules directory');
|
|
35
38
|
return String(result.stdout || '').trim();
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
function localRuntimeEntry(runtimeValue) {
|
|
41
|
+
export function localRuntimeEntry(runtimeValue) {
|
|
39
42
|
const runtime = normalizeRuntime(runtimeValue);
|
|
40
43
|
const candidate = path.join(globalNodeModules(), ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
|
|
41
44
|
if (!existsSync(candidate)) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
|
|
@@ -116,7 +119,15 @@ async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepai
|
|
|
116
119
|
await onRuntimeRepaired?.();
|
|
117
120
|
return;
|
|
118
121
|
}
|
|
119
|
-
|
|
122
|
+
// Handing over to a version that cannot start would take the machine offline with nobody left to
|
|
123
|
+
// retry. The new CLI has to answer `--version` before this process steps aside.
|
|
124
|
+
const probe = spawnSync(process.execPath, [cli, '--version'], { encoding: 'utf8', timeout: 30000 });
|
|
125
|
+
const reported = String(probe.stdout || '').trim();
|
|
126
|
+
if (probe.error || probe.status !== 0 || !/^\d+\.\d+\.\d+/.test(reported)) {
|
|
127
|
+
console.error(`The installed ReMCP ${installed || 'update'} did not run (${probe.error?.message || `exit ${probe.status}`}${reported ? `: ${reported}` : ''}). Keeping ${VERSION} running; retry with: remcp update`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
console.log(`ReMCP ${installed || 'a newer version'} installed and verified (${reported}); restarting to apply it.`);
|
|
120
131
|
if (!supervisorRestart()) {
|
|
121
132
|
spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
|
|
122
133
|
}
|
|
@@ -154,7 +165,7 @@ function supervisorRestart() {
|
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
function globalCliEntry() {
|
|
157
|
-
const prefix = spawnSync(
|
|
168
|
+
const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
|
|
158
169
|
if (prefix.error || prefix.status !== 0) return null;
|
|
159
170
|
const base = String(prefix.stdout || '').trim();
|
|
160
171
|
return process.platform === 'win32'
|
package/src/cli.mjs
CHANGED
|
@@ -4,7 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { runAgent } from './agent.mjs';
|
|
7
|
+
import { localRuntimeEntry, runAgent } from './agent.mjs';
|
|
8
|
+
import { npmVersion, resolveNpm } from './npm.mjs';
|
|
8
9
|
import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
|
|
9
10
|
import { PACKAGE_NAME, VERSION } from './version.mjs';
|
|
10
11
|
|
|
@@ -18,7 +19,9 @@ const macServiceLabel = 'com.remcp.agent';
|
|
|
18
19
|
const macServiceFile = path.join(home, 'Library', 'LaunchAgents', `${macServiceLabel}.plist`);
|
|
19
20
|
const macLogFile = path.join(home, 'Library', 'Logs', 'remcp-agent.log');
|
|
20
21
|
const windowsTaskName = 'ReMCP Agent';
|
|
21
|
-
|
|
22
|
+
// How npm is invoked is resolved from the running node when possible: a background service has a
|
|
23
|
+
// minimal PATH, which is why auto-update used to find no npm on macOS. See src/npm.mjs.
|
|
24
|
+
const npm = resolveNpm();
|
|
22
25
|
const officialOrigin = 'https://remcp.delio24.com';
|
|
23
26
|
|
|
24
27
|
function parse(argv) {
|
|
@@ -130,7 +133,7 @@ function servicePlatform() {
|
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
function globalPrefix() {
|
|
133
|
-
return output(
|
|
136
|
+
return output(npm.command, [...npm.args, 'prefix', '--global']);
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
function globalCliPath() {
|
|
@@ -139,7 +142,7 @@ function globalCliPath() {
|
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
function npmGlobalInstall(...specs) {
|
|
142
|
-
run(
|
|
145
|
+
run(npm.command, [...npm.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
function quoteSystemd(value) {
|
|
@@ -192,9 +195,28 @@ function installPersistentAgent(config) {
|
|
|
192
195
|
if (platform === 'linux') installLinuxService(cliPath);
|
|
193
196
|
else if (platform === 'darwin') installMacService(cliPath);
|
|
194
197
|
else installWindowsService(cliPath);
|
|
198
|
+
saveConfig({ ...config, serviceInstalled: true });
|
|
195
199
|
console.log('ReMCP is installed as a background service. Future updates: remcp update');
|
|
196
200
|
}
|
|
197
201
|
|
|
202
|
+
// A machine that was installed as a service must still be one after an update: if the job is missing
|
|
203
|
+
// (a failed install, a cleaned LaunchAgents directory, a re-imaged user), the next update recreates
|
|
204
|
+
// it instead of leaving a hand-over to a process nobody supervises.
|
|
205
|
+
function ensureServiceIfRecorded(config) {
|
|
206
|
+
if (config?.serviceInstalled !== true) return false;
|
|
207
|
+
try {
|
|
208
|
+
const cliPath = globalCliPath();
|
|
209
|
+
const platform = servicePlatform();
|
|
210
|
+
if (platform === 'linux') { if (!fs.existsSync(linuxServiceFile)) installLinuxService(cliPath); }
|
|
211
|
+
else if (platform === 'darwin') { if (!fs.existsSync(macServiceFile)) installMacService(cliPath); }
|
|
212
|
+
else if (platform === 'win32') installWindowsService(cliPath);
|
|
213
|
+
return true;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.error(`Could not ensure the background service: ${error instanceof Error ? error.message : String(error)}`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
198
220
|
function restartPersistentServiceIfInstalled() {
|
|
199
221
|
const platform = servicePlatform();
|
|
200
222
|
if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
|
|
@@ -228,10 +250,66 @@ function supervisorRestart() {
|
|
|
228
250
|
return null;
|
|
229
251
|
}
|
|
230
252
|
|
|
253
|
+
// One real handshake with the local runtime, plus everything needed to explain a failure: where the
|
|
254
|
+
// entry resolved, whether the package is installed, the node that would run it, and the exact error.
|
|
255
|
+
async function diagnoseLocalRuntime(cfg) {
|
|
256
|
+
const packageName = cfg.runtime?.packageName || '';
|
|
257
|
+
const diagnosis = {
|
|
258
|
+
platform: `${process.platform} ${process.arch}`,
|
|
259
|
+
node: process.execPath,
|
|
260
|
+
nodeVersion: process.versions.node,
|
|
261
|
+
packageName,
|
|
262
|
+
packageSpec: cfg.runtime?.packageSpec || '',
|
|
263
|
+
installedRuntime: installedVersion(packageName),
|
|
264
|
+
installedClient: installedVersion(PACKAGE_NAME),
|
|
265
|
+
};
|
|
266
|
+
const resolved = resolveNpm();
|
|
267
|
+
const npmInfo = npmVersion(resolved);
|
|
268
|
+
diagnosis.npm = npmInfo ? { version: npmInfo.version, source: npmInfo.source } : { error: `npm could not be executed (tried ${resolved.source})` };
|
|
269
|
+
let entry = '';
|
|
270
|
+
try {
|
|
271
|
+
entry = localRuntimeEntry(cfg.runtime);
|
|
272
|
+
diagnosis.entry = entry;
|
|
273
|
+
diagnosis.entryExists = fs.existsSync(entry);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
diagnosis.entry = null;
|
|
276
|
+
diagnosis.entryExists = false;
|
|
277
|
+
diagnosis.verdict = 'runtime-not-installed';
|
|
278
|
+
diagnosis.error = error instanceof Error ? error.message : String(error);
|
|
279
|
+
diagnosis.hint = `Reinstall with: npx --yes ${PACKAGE_NAME}@latest update`;
|
|
280
|
+
return diagnosis;
|
|
281
|
+
}
|
|
282
|
+
if (!diagnosis.entryExists) {
|
|
283
|
+
diagnosis.verdict = 'runtime-entry-missing';
|
|
284
|
+
diagnosis.hint = `Reinstall with: npx --yes ${PACKAGE_NAME}@latest update`;
|
|
285
|
+
return diagnosis;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
289
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
|
|
290
|
+
const client = new Client({ name: 'remcp-doctor', version: VERSION });
|
|
291
|
+
const stdio = new StdioClientTransport({ command: process.execPath, args: [entry], env: { ...process.env }, maxBufferSize: 4 * 1024 * 1024 });
|
|
292
|
+
const stderr = [];
|
|
293
|
+
stdio.onerror = error => stderr.push(String(error?.message || error));
|
|
294
|
+
await client.connect(stdio);
|
|
295
|
+
diagnosis.runtimeVersion = client.getServerVersion()?.version || 'unknown';
|
|
296
|
+
const tools = await client.listTools(undefined, { timeout: 20000 });
|
|
297
|
+
diagnosis.tools = tools.tools.length;
|
|
298
|
+
diagnosis.verdict = 'ok';
|
|
299
|
+
await client.close();
|
|
300
|
+
return diagnosis;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
diagnosis.verdict = 'runtime-handshake-failed';
|
|
303
|
+
diagnosis.error = error instanceof Error ? error.message : String(error);
|
|
304
|
+
diagnosis.hint = 'Run the entry above by hand to see its output, then reinstall with: npx --yes @remcp/remcp@latest update';
|
|
305
|
+
return diagnosis;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
231
309
|
// Reads the version a freshly installed global package reports, so an update that installed
|
|
232
310
|
// nothing (wrong prefix, npm cache, permissions) is reported instead of assumed successful.
|
|
233
311
|
function installedVersion(packageName) {
|
|
234
|
-
const prefix = spawnSync(
|
|
312
|
+
const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
|
|
235
313
|
if (prefix.error || prefix.status !== 0) return null;
|
|
236
314
|
const manifest = path.join(String(prefix.stdout || '').trim(), 'lib', 'node_modules', ...packageName.split('/'), 'package.json');
|
|
237
315
|
try { return JSON.parse(fs.readFileSync(manifest, 'utf8')).version || null; } catch { return null; }
|
|
@@ -353,8 +431,25 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
353
431
|
|
|
354
432
|
if (command === 'status' || command === 'doctor') {
|
|
355
433
|
const cfg = loadConfig();
|
|
356
|
-
const health = await fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache: 'no-store' }).then(r => r.json());
|
|
357
|
-
|
|
434
|
+
const health = await fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache: 'no-store' }).then(r => r.json()).catch(error => ({ error: error.message }));
|
|
435
|
+
const report = {
|
|
436
|
+
configured: true,
|
|
437
|
+
cliVersion: VERSION,
|
|
438
|
+
deviceId: cfg.deviceId,
|
|
439
|
+
deviceName: cfg.deviceName,
|
|
440
|
+
server: cfg.serverUrl,
|
|
441
|
+
runtime: cfg.runtime,
|
|
442
|
+
telemetry: telemetryState(),
|
|
443
|
+
serverHealth: health,
|
|
444
|
+
};
|
|
445
|
+
// `doctor` answers the question the workspace cannot: is this machine actually able to run a
|
|
446
|
+
// tool? It resolves the runtime entry, installs nothing, and tries one real MCP handshake with
|
|
447
|
+
// the runtime, so the failure is visible here instead of only as "runtime not running".
|
|
448
|
+
if (command === 'doctor') {
|
|
449
|
+
report.diagnosis = await diagnoseLocalRuntime(cfg);
|
|
450
|
+
}
|
|
451
|
+
console.log(JSON.stringify(report, null, 2));
|
|
452
|
+
if (command === 'doctor' && report.diagnosis.verdict !== 'ok') process.exitCode = 1;
|
|
358
453
|
return;
|
|
359
454
|
}
|
|
360
455
|
|
|
@@ -401,6 +496,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
401
496
|
return;
|
|
402
497
|
}
|
|
403
498
|
const before = { cli: VERSION, runtime: installedVersion(cfg.runtime.packageName) };
|
|
499
|
+
ensureServiceIfRecorded(cfg);
|
|
404
500
|
console.log(`Updating ReMCP to the latest published version (${runtimeSpec})…`);
|
|
405
501
|
npmGlobalInstall(`${PACKAGE_NAME}@latest`, runtimeSpec);
|
|
406
502
|
// Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
|
|
@@ -425,7 +521,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
425
521
|
if (flags.purge) {
|
|
426
522
|
const cfg = loadConfig(false);
|
|
427
523
|
const specs = [PACKAGE_NAME, ...(cfg?.runtime?.packageName ? [cfg.runtime.packageName] : [])];
|
|
428
|
-
run(
|
|
524
|
+
run(npm.command, [...npm.args, 'uninstall', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
429
525
|
}
|
|
430
526
|
return;
|
|
431
527
|
}
|
package/src/npm.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
// A background service does not inherit the PATH a terminal has: a launchd agent, a systemd unit and
|
|
8
|
+
// a Windows scheduled task all start with a minimal environment where `npm` is often not on PATH at
|
|
9
|
+
// all. That is how auto-update silently stopped working on macOS — the agent looked for `npm`, did
|
|
10
|
+
// not find it, and told the user to run the command by hand.
|
|
11
|
+
//
|
|
12
|
+
// npm is a JavaScript entry point, so the reliable answer is to run it with the same node that is
|
|
13
|
+
// already executing us, and only fall back to a PATH lookup. The resolver also accepts an explicit
|
|
14
|
+
// override (REMCP_NPM) for unusual installations.
|
|
15
|
+
const NPM_CLI_RELATIVE = ['lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'];
|
|
16
|
+
|
|
17
|
+
export function npmCandidates({ nodePath = process.execPath, home = os.homedir(), platform = process.platform } = {}) {
|
|
18
|
+
const nodeDir = path.dirname(nodePath);
|
|
19
|
+
const cli = [];
|
|
20
|
+
// nvm, n, the official installer and the Docker image all place npm beside node like this.
|
|
21
|
+
cli.push(path.join(nodeDir, '..', NPM_CLI_RELATIVE.join(path.sep)));
|
|
22
|
+
cli.push(path.join(nodeDir, NPM_CLI_RELATIVE.join(path.sep)));
|
|
23
|
+
if (platform === 'darwin') {
|
|
24
|
+
cli.push(path.join('/opt/homebrew', NPM_CLI_RELATIVE.join(path.sep)));
|
|
25
|
+
cli.push(path.join('/usr/local', NPM_CLI_RELATIVE.join(path.sep)));
|
|
26
|
+
} else if (platform === 'win32') {
|
|
27
|
+
cli.push(path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'));
|
|
28
|
+
} else {
|
|
29
|
+
cli.push(path.join('/usr', NPM_CLI_RELATIVE.join(path.sep)));
|
|
30
|
+
cli.push(path.join('/usr/local', NPM_CLI_RELATIVE.join(path.sep)));
|
|
31
|
+
}
|
|
32
|
+
cli.push(path.join(home, '.local', NPM_CLI_RELATIVE.join(path.sep)));
|
|
33
|
+
const binaries = [
|
|
34
|
+
path.join(nodeDir, platform === 'win32' ? 'npm.cmd' : 'npm'),
|
|
35
|
+
path.join(nodeDir, '..', 'bin', platform === 'win32' ? 'npm.cmd' : 'npm'),
|
|
36
|
+
platform === 'darwin' ? '/opt/homebrew/bin/npm' : '',
|
|
37
|
+
platform === 'win32' ? '' : '/usr/local/bin/npm',
|
|
38
|
+
platform === 'win32' ? '' : '/usr/bin/npm',
|
|
39
|
+
platform === 'win32' ? '' : path.join(home, '.local', 'bin', 'npm'),
|
|
40
|
+
].filter(Boolean);
|
|
41
|
+
return { cli: [...new Set(cli)], binaries: [...new Set(binaries)] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Resolves how to run npm. `source` is reported by `remcp doctor` and logged at agent startup so a
|
|
45
|
+
// machine where npm cannot be found is obvious before an update is needed.
|
|
46
|
+
export function resolveNpm({ nodePath = process.execPath, home = os.homedir(), platform = process.platform } = {}) {
|
|
47
|
+
const override = String(process.env.REMCP_NPM || '').trim();
|
|
48
|
+
if (override) return { command: override, args: [], source: `REMCP_NPM=${override}` };
|
|
49
|
+
const { cli, binaries } = npmCandidates({ nodePath, home, platform });
|
|
50
|
+
for (const candidate of cli) {
|
|
51
|
+
if (existsSync(candidate)) return { command: nodePath, args: [candidate], source: `node ${candidate}` };
|
|
52
|
+
}
|
|
53
|
+
for (const candidate of binaries) {
|
|
54
|
+
if (existsSync(candidate)) return { command: candidate, args: [], source: candidate };
|
|
55
|
+
}
|
|
56
|
+
return { command: platform === 'win32' ? 'npm.cmd' : 'npm', args: [], source: 'PATH' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The version npm itself reports, or null when it cannot be executed at all.
|
|
60
|
+
export function npmVersion(resolved = resolveNpm()) {
|
|
61
|
+
const result = spawnSync(resolved.command, [...resolved.args, '--version'], { encoding: 'utf8', timeout: 15000 });
|
|
62
|
+
if (result.error || result.status !== 0) {
|
|
63
|
+
// A login shell may still find npm (nvm and Homebrew write their PATH into the profile).
|
|
64
|
+
const shell = process.platform === 'win32' ? null : spawnSync('/bin/sh', ['-lc', 'command -v npm'], { encoding: 'utf8', timeout: 15000 });
|
|
65
|
+
const found = String(shell?.stdout || '').trim().split('\n').pop();
|
|
66
|
+
if (found && existsSync(found)) {
|
|
67
|
+
const retry = spawnSync(found, ['--version'], { encoding: 'utf8', timeout: 15000 });
|
|
68
|
+
if (!retry.error && retry.status === 0) return { version: String(retry.stdout).trim(), source: found };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return { version: String(result.stdout).trim(), source: resolved.source };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Runs npm with the resolved command, so callers never depend on PATH.
|
|
76
|
+
export function npmRun(args, { encoding = 'utf8', stdio = 'inherit' } = {}) {
|
|
77
|
+
const resolved = resolveNpm();
|
|
78
|
+
return spawnSync(resolved.command, [...resolved.args, ...args], { encoding, stdio });
|
|
79
|
+
}
|