@remcp/remcp 0.1.0 → 0.1.1
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 +29 -12
- package/package.json +1 -2
- package/public/authorize.html +3 -3
- package/public/copy.js +49 -0
- package/public/docs.html +50 -14
- package/public/index.html +29 -5
- package/public/privacy.html +4 -6
- package/public/security.html +4 -6
- package/public/style.css +46 -18
- package/public/support.html +8 -6
- package/public/terms.html +4 -6
- package/src/agent.mjs +4 -3
- package/src/cli.mjs +104 -32
- package/src/config.mjs +1 -1
- package/src/mcp.mjs +11 -6
- package/src/server.mjs +6 -5
- package/src/tool-catalog.json +24 -64
- package/src/version.mjs +8 -0
package/src/agent.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
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 { VERSION } from './version.mjs';
|
|
9
10
|
|
|
10
11
|
function desktopCommanderEntry() {
|
|
11
12
|
try { return fileURLToPath(import.meta.resolve('@wonderwhy-er/desktop-commander')); } catch {}
|
|
@@ -31,10 +32,10 @@ export async function runAgent(options) {
|
|
|
31
32
|
let stopping = false;
|
|
32
33
|
let activeSocket;
|
|
33
34
|
|
|
34
|
-
const mcp = new Client({ name: 'remcp-agent', version:
|
|
35
|
+
const mcp = new Client({ name: 'remcp-agent', version: VERSION });
|
|
35
36
|
const transport = new StdioClientTransport({ command: process.execPath, args: [desktopCommanderEntry()] });
|
|
36
37
|
await mcp.connect(transport);
|
|
37
|
-
console.log('Local
|
|
38
|
+
console.log('Local device runtime ready');
|
|
38
39
|
|
|
39
40
|
async function respond(ws, message) {
|
|
40
41
|
try {
|
|
@@ -54,7 +55,7 @@ export async function runAgent(options) {
|
|
|
54
55
|
const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
|
|
55
56
|
activeSocket = ws;
|
|
56
57
|
ws.on('open', () => {
|
|
57
|
-
ws.send(JSON.stringify({ type: 'hello', deviceId, deviceName, hostname: os.hostname(), platform: process.platform, arch: process.arch, agentVersion:
|
|
58
|
+
ws.send(JSON.stringify({ type: 'hello', deviceId, deviceName, hostname: os.hostname(), platform: process.platform, arch: process.arch, agentVersion: VERSION }));
|
|
58
59
|
console.log(`Connected to ${agentUrl} as ${deviceName} (${deviceId})`);
|
|
59
60
|
});
|
|
60
61
|
ws.on('message', raw => {
|
package/src/cli.mjs
CHANGED
|
@@ -2,11 +2,15 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import process from 'node:process';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
5
6
|
import { runAgent } from './agent.mjs';
|
|
7
|
+
import { LOCAL_RUNTIME_NAME, LOCAL_RUNTIME_SPEC, PACKAGE_NAME, VERSION } from './version.mjs';
|
|
6
8
|
|
|
7
9
|
const home = os.homedir();
|
|
8
10
|
const configDir = process.env.REMCP_CONFIG_DIR || path.join(home, '.config', 'remcp');
|
|
9
11
|
const configFile = path.join(configDir, 'config.json');
|
|
12
|
+
const serviceFile = path.join(home, '.config', 'systemd', 'user', 'remcp-agent.service');
|
|
13
|
+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
10
14
|
|
|
11
15
|
function parse(argv) {
|
|
12
16
|
const [command = 'help', ...rest] = argv;
|
|
@@ -18,73 +22,141 @@ function parse(argv) {
|
|
|
18
22
|
}
|
|
19
23
|
return { command, flags };
|
|
20
24
|
}
|
|
25
|
+
|
|
26
|
+
function run(command, args, options = {}) {
|
|
27
|
+
const result = spawnSync(command, args, { stdio: 'inherit', ...options });
|
|
28
|
+
if (result.error) throw result.error;
|
|
29
|
+
if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status}`);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function output(command, args) {
|
|
34
|
+
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
35
|
+
if (result.error) throw result.error;
|
|
36
|
+
if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status}`);
|
|
37
|
+
return String(result.stdout || '').trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
function loadConfig() {
|
|
22
|
-
if (!fs.existsSync(configFile)) throw new Error(`ReMCP is not paired. Run:
|
|
41
|
+
if (!fs.existsSync(configFile)) throw new Error(`ReMCP is not paired. Run: npx --yes ${PACKAGE_NAME}@latest connect --server https://remcp.delio24.com --code CODE --install`);
|
|
23
42
|
return JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
24
43
|
}
|
|
44
|
+
|
|
25
45
|
function saveConfig(value) {
|
|
26
46
|
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
27
47
|
fs.writeFileSync(configFile, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
|
28
48
|
fs.chmodSync(configFile, 0o600);
|
|
29
49
|
}
|
|
30
|
-
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
|
|
51
|
+
function globalPrefix() {
|
|
52
|
+
return output(npmCommand, ['prefix', '--global']);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function globalCliPath() {
|
|
56
|
+
const prefix = globalPrefix();
|
|
57
|
+
return process.platform === 'win32' ? path.join(prefix, 'remcp.cmd') : path.join(prefix, 'bin', 'remcp');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function npmGlobalInstall(packageSpec) {
|
|
61
|
+
run(npmCommand, ['install', '--global', packageSpec, LOCAL_RUNTIME_SPEC, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function quoteSystemd(value) {
|
|
65
|
+
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function installLinuxService(cliPath = globalCliPath()) {
|
|
69
|
+
const unit = `[Unit]\nDescription=ReMCP device agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${quoteSystemd(cliPath)} start\nRestart=always\nRestartSec=3\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
|
|
70
|
+
fs.mkdirSync(path.dirname(serviceFile), { recursive: true });
|
|
71
|
+
fs.writeFileSync(serviceFile, unit);
|
|
72
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
73
|
+
run('systemctl', ['--user', 'enable', '--now', 'remcp-agent.service']);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function installPersistentAgent() {
|
|
77
|
+
if (process.platform !== 'linux') throw new Error('Automatic background service installation currently supports Linux');
|
|
78
|
+
console.log(`Installing ${PACKAGE_NAME}@${VERSION} and the local device runtime…`);
|
|
79
|
+
npmGlobalInstall(`${PACKAGE_NAME}@${VERSION}`);
|
|
80
|
+
installLinuxService(globalCliPath());
|
|
81
|
+
console.log('ReMCP is installed as a user service. Future updates: remcp update');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function restartLinuxServiceIfInstalled() {
|
|
85
|
+
if (process.platform !== 'linux' || !fs.existsSync(serviceFile)) return;
|
|
86
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
87
|
+
run('systemctl', ['--user', 'restart', 'remcp-agent.service']);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function uninstallLinuxService() {
|
|
91
|
+
if (process.platform !== 'linux') return;
|
|
92
|
+
spawnSync('systemctl', ['--user', 'disable', '--now', 'remcp-agent.service'], { stdio: 'inherit' });
|
|
93
|
+
try { fs.unlinkSync(serviceFile); } catch {}
|
|
36
94
|
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
|
|
37
|
-
spawnSync('systemctl', ['--user', 'enable', '--now', 'remcp-agent.service'], { stdio: 'inherit' });
|
|
38
95
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (result.status !== 0) throw new Error(`${command} failed`);
|
|
43
|
-
return result;
|
|
44
|
-
}};
|
|
96
|
+
|
|
97
|
+
function printHelp() {
|
|
98
|
+
console.log(`ReMCP ${VERSION}\n\nCommands:\n npx --yes ${PACKAGE_NAME}@latest connect --server https://remcp.delio24.com --code ABC12345 --install\n remcp start\n remcp status\n remcp doctor\n remcp update\n remcp install\n remcp uninstall\n remcp uninstall --purge\n remcp --version`);
|
|
45
99
|
}
|
|
46
|
-
let childModule;
|
|
47
|
-
function requireChild() { if (!childModule) throw new Error('child_process not initialized'); return childModule; }
|
|
48
100
|
|
|
49
101
|
export async function main(argv = process.argv.slice(2)) {
|
|
50
|
-
childModule = await import('node:child_process');
|
|
51
102
|
const { command, flags } = parse(argv);
|
|
103
|
+
|
|
104
|
+
if (command === '--version' || command === '-v' || command === 'version') {
|
|
105
|
+
console.log(VERSION);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
52
109
|
if (command === 'connect') {
|
|
53
110
|
const server = String(flags.server || '').replace(/\/$/, '');
|
|
54
111
|
const code = String(flags.code || '').toUpperCase();
|
|
55
112
|
if (!server || !code) throw new Error('--server and --code are required');
|
|
56
113
|
const response = await fetch(`${server}/api/pair/claim`, {
|
|
57
|
-
method: 'POST',
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'content-type': 'application/json' },
|
|
58
116
|
body: JSON.stringify({ code, name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
|
|
59
117
|
});
|
|
60
118
|
if (!response.ok) throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
|
|
61
119
|
const paired = await response.json();
|
|
62
120
|
saveConfig({ serverUrl: server, deviceId: paired.deviceId, deviceToken: paired.deviceToken, deviceName: String(flags.name || os.hostname()) });
|
|
63
121
|
console.log(`Paired ${os.hostname()} with ${server}`);
|
|
64
|
-
if (flags.install)
|
|
65
|
-
if (process.platform === 'linux') installLinuxService();
|
|
66
|
-
else console.log('Automatic service installation currently supports Linux. Run `remcp start` or install a launch service for this user.');
|
|
67
|
-
}
|
|
122
|
+
if (flags.install) installPersistentAgent();
|
|
68
123
|
return;
|
|
69
124
|
}
|
|
70
|
-
|
|
125
|
+
|
|
126
|
+
if (command === 'start') {
|
|
127
|
+
await runAgent(loadConfig());
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
71
131
|
if (command === 'status' || command === 'doctor') {
|
|
72
132
|
const cfg = loadConfig();
|
|
73
|
-
const health = await fetch(`${cfg.serverUrl}/health
|
|
74
|
-
console.log(JSON.stringify({ configured: true, deviceId: cfg.deviceId, deviceName: cfg.deviceName, server: cfg.serverUrl, serverHealth: health }, null, 2));
|
|
133
|
+
const health = await fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache: 'no-store' }).then(r => r.json());
|
|
134
|
+
console.log(JSON.stringify({ configured: true, cliVersion: VERSION, deviceId: cfg.deviceId, deviceName: cfg.deviceName, server: cfg.serverUrl, serverHealth: health }, null, 2));
|
|
75
135
|
return;
|
|
76
136
|
}
|
|
137
|
+
|
|
77
138
|
if (command === 'install') {
|
|
78
|
-
|
|
79
|
-
|
|
139
|
+
loadConfig();
|
|
140
|
+
installPersistentAgent();
|
|
141
|
+
return;
|
|
80
142
|
}
|
|
143
|
+
|
|
144
|
+
if (command === 'update') {
|
|
145
|
+
console.log(`Updating ${PACKAGE_NAME} to the latest published version…`);
|
|
146
|
+
npmGlobalInstall(`${PACKAGE_NAME}@latest`);
|
|
147
|
+
restartLinuxServiceIfInstalled();
|
|
148
|
+
console.log('ReMCP updated. Run `remcp --version` or `remcp status` to verify.');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
81
152
|
if (command === 'uninstall') {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
153
|
+
uninstallLinuxService();
|
|
154
|
+
if (flags.purge) {
|
|
155
|
+
console.log('Removing global ReMCP packages…');
|
|
156
|
+
run(npmCommand, ['uninstall', '--global', PACKAGE_NAME, LOCAL_RUNTIME_NAME, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
86
157
|
}
|
|
87
158
|
return;
|
|
88
159
|
}
|
|
89
|
-
|
|
160
|
+
|
|
161
|
+
printHelp();
|
|
90
162
|
}
|
package/src/config.mjs
CHANGED
|
@@ -22,7 +22,7 @@ function loadSigningSecret() {
|
|
|
22
22
|
return generated;
|
|
23
23
|
}
|
|
24
24
|
const tokenSecret = loadSigningSecret();
|
|
25
|
-
const installSpec = (process.env.REMCP_INSTALL_SPEC || '@remcp/remcp').trim();
|
|
25
|
+
const installSpec = (process.env.REMCP_INSTALL_SPEC || '@remcp/remcp@latest').trim();
|
|
26
26
|
if (!/^[A-Za-z0-9@._:/#-]+$/.test(installSpec)) throw new Error('REMCP_INSTALL_SPEC contains unsupported characters');
|
|
27
27
|
|
|
28
28
|
export const config = Object.freeze({
|
package/src/mcp.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { VERSION } from './version.mjs';
|
|
4
5
|
|
|
5
6
|
const rawCatalog = JSON.parse(readFileSync(new URL('./tool-catalog.json', import.meta.url), 'utf8'));
|
|
6
7
|
const destructive = new Set(['set_config_value','write_file','write_pdf','move_file','edit_block','start_process','interact_with_process','force_terminate','kill_process']);
|
|
7
8
|
const openWorld = new Set(['start_process','interact_with_process']);
|
|
8
9
|
const descriptions = {
|
|
9
|
-
get_config: 'Read the local
|
|
10
|
-
set_config_value: 'Change one supported
|
|
10
|
+
get_config: 'Read the local device runtime configuration for the selected paired device.',
|
|
11
|
+
set_config_value: 'Change one supported local device runtime configuration value on the selected paired device.',
|
|
11
12
|
read_file: 'Read a local file or a user-supplied URL from the selected paired device.',
|
|
12
13
|
read_multiple_files: 'Read multiple local files from the selected paired device in one call.',
|
|
13
14
|
write_file: 'Create, replace, or append to a file on the selected paired device.',
|
|
@@ -28,10 +29,14 @@ const descriptions = {
|
|
|
28
29
|
list_sessions: 'List active terminal sessions on the selected paired device.',
|
|
29
30
|
list_processes: 'List running operating-system processes on the selected paired device.',
|
|
30
31
|
kill_process: 'Terminate a process by PID on the selected paired device.',
|
|
31
|
-
get_usage_stats: 'Read local
|
|
32
|
-
get_recent_tool_calls: 'Read recent local
|
|
32
|
+
get_usage_stats: 'Read local device runtime usage statistics from the selected paired device.',
|
|
33
|
+
get_recent_tool_calls: 'Read recent local device runtime tool-call history from the selected paired device.',
|
|
33
34
|
};
|
|
34
35
|
|
|
36
|
+
function publicDescription(tool) {
|
|
37
|
+
return String(descriptions[tool.name] || tool.description || '').replace(/\s+/g, ' ').trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
35
40
|
function publicTool(tool) {
|
|
36
41
|
const schema = structuredClone(tool.inputSchema || { type: 'object', properties: {} });
|
|
37
42
|
schema.type = 'object';
|
|
@@ -45,7 +50,7 @@ function publicTool(tool) {
|
|
|
45
50
|
return {
|
|
46
51
|
name: tool.name,
|
|
47
52
|
title: upstream.title || tool.name,
|
|
48
|
-
description:
|
|
53
|
+
description: publicDescription(tool),
|
|
49
54
|
inputSchema: schema,
|
|
50
55
|
annotations: {
|
|
51
56
|
title: upstream.title || tool.name,
|
|
@@ -68,7 +73,7 @@ const listDevicesTool = {
|
|
|
68
73
|
};
|
|
69
74
|
|
|
70
75
|
export function createMcpServer(uid, relay) {
|
|
71
|
-
const server = new Server({ name: 'remcp', version:
|
|
76
|
+
const server = new Server({ name: 'remcp', version: VERSION }, { capabilities: { tools: {} } });
|
|
72
77
|
|
|
73
78
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [listDevicesTool, ...catalog] }));
|
|
74
79
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
package/src/server.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { oauthRouter } from './oauth.mjs';
|
|
|
13
13
|
import { createRelay } from './relay.mjs';
|
|
14
14
|
import { createMcpServer } from './mcp.mjs';
|
|
15
15
|
import { now, randomId, randomToken, sha256 } from './util.mjs';
|
|
16
|
+
import { VERSION } from './version.mjs';
|
|
16
17
|
|
|
17
18
|
const app = express();
|
|
18
19
|
app.set('trust proxy', 1);
|
|
@@ -22,15 +23,15 @@ app.use(helmet({
|
|
|
22
23
|
contentSecurityPolicy: {
|
|
23
24
|
directives: {
|
|
24
25
|
defaultSrc: ["'self'"],
|
|
25
|
-
scriptSrc: ["'self'", 'https://www.gstatic.com'],
|
|
26
|
-
connectSrc: ["'self'", 'https://identitytoolkit.googleapis.com', 'https://securetoken.googleapis.com', 'https://www.googleapis.com'],
|
|
26
|
+
scriptSrc: ["'self'", 'https://www.gstatic.com', 'https://apis.google.com', 'https://www.google.com'],
|
|
27
|
+
connectSrc: ["'self'", 'https://identitytoolkit.googleapis.com', 'https://securetoken.googleapis.com', 'https://www.googleapis.com', 'https://apis.google.com', 'https://accounts.google.com'],
|
|
27
28
|
imgSrc: ["'self'", 'data:'],
|
|
28
29
|
styleSrc: ["'self'"],
|
|
29
30
|
fontSrc: ["'none'"],
|
|
30
|
-
frameSrc: [firebaseFrame, 'https://accounts.google.com'],
|
|
31
|
+
frameSrc: [firebaseFrame, 'https://accounts.google.com', 'https://apis.google.com'],
|
|
31
32
|
objectSrc: ["'none'"],
|
|
32
33
|
baseUri: ["'none'"],
|
|
33
|
-
formAction: ["'self'"],
|
|
34
|
+
formAction: ["'self'", 'https://accounts.google.com'],
|
|
34
35
|
frameAncestors: ["'none'"],
|
|
35
36
|
},
|
|
36
37
|
},
|
|
@@ -57,7 +58,7 @@ const relay = createRelay(httpServer);
|
|
|
57
58
|
const sessions = new Map();
|
|
58
59
|
const publicDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../public');
|
|
59
60
|
|
|
60
|
-
app.get('/health', (_req, res) => res.json({ ok: true, service: 'remcp', version:
|
|
61
|
+
app.get('/health', (_req, res) => res.json({ ok: true, service: 'remcp', version: VERSION, connectedDevices: relay.onlineCount() }));
|
|
61
62
|
app.get(config.domainVerificationPath, (_req, res) => {
|
|
62
63
|
if (!config.domainVerificationToken) return res.status(404).type('text/plain').send('Not configured');
|
|
63
64
|
res.type('text/plain').send(config.domainVerificationToken);
|