@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.
- package/README.md +41 -113
- package/package.json +10 -24
- package/src/agent.mjs +215 -36
- package/src/cli.mjs +217 -43
- package/src/runtime.mjs +15 -0
- package/src/version.mjs +1 -3
- package/THIRD_PARTY_NOTICES.md +0 -7
- package/assets/remcp-icon.png +0 -0
- package/mcp.json +0 -9
- package/plugin.json +0 -37
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/app.js +0 -96
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/art/hero-relay.webp +0 -0
- package/public/assets/art/relay-detail.webp +0 -0
- package/public/assets/icon-120.png +0 -0
- package/public/assets/icon-16.png +0 -0
- package/public/assets/icon-180.png +0 -0
- package/public/assets/icon-192.png +0 -0
- package/public/assets/icon-256.png +0 -0
- package/public/assets/icon-32.png +0 -0
- package/public/assets/icon-48.png +0 -0
- package/public/assets/icon-512.png +0 -0
- package/public/assets/og-remcp.png +0 -0
- package/public/authorize.html +0 -59
- package/public/copy.js +0 -40
- package/public/docs.html +0 -93
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon-48x48.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +0 -134
- package/public/oauth-app.js +0 -63
- package/public/privacy.html +0 -80
- package/public/remcp-darkmode-16x16.png +0 -0
- package/public/remcp-darkmode-192x192.png +0 -0
- package/public/remcp-darkmode-32x32.png +0 -0
- package/public/remcp-darkmode-48x48.png +0 -0
- package/public/remcp-darkmode-512x512.png +0 -0
- package/public/security.html +0 -70
- package/public/site.webmanifest +0 -23
- package/public/style.css +0 -261
- package/public/support.html +0 -75
- package/public/terms.html +0 -77
- package/skills/remcp-operator/SKILL.md +0 -36
- package/src/auth.mjs +0 -71
- package/src/config.mjs +0 -51
- package/src/db.mjs +0 -102
- package/src/mcp.mjs +0 -95
- package/src/oauth.mjs +0 -164
- package/src/relay.mjs +0 -97
- package/src/review-sandbox.mjs +0 -96
- package/src/server.mjs +0 -159
- package/src/tool-catalog.json +0 -796
- package/src/util.mjs +0 -24
package/README.md
CHANGED
|
@@ -1,143 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Device client
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
##
|
|
17
|
+
## Commands
|
|
46
18
|
|
|
47
19
|
```text
|
|
48
|
-
remcp start
|
|
49
|
-
remcp status
|
|
50
|
-
remcp doctor
|
|
51
|
-
remcp update
|
|
52
|
-
remcp install
|
|
53
|
-
remcp uninstall
|
|
54
|
-
remcp uninstall --purge
|
|
55
|
-
remcp
|
|
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
|
-
|
|
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
|
-
##
|
|
34
|
+
## What runs on your computer
|
|
77
35
|
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
46
|
+
## Usage metrics
|
|
88
47
|
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
71
|
+
MIT.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remcp/remcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
28
|
-
"
|
|
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
|
-
"
|
|
60
|
-
"
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 === '
|
|
44
|
-
|
|
45
|
-
else if (
|
|
46
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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',
|
|
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
|
|
75
|
-
try { await transport
|
|
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
|
}
|