@remcp/runtime 0.2.19 → 0.2.20
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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/catalog.mjs +18 -0
- package/src/config.mjs +56 -1
- package/src/sessions.mjs +2 -2
- package/src/telemetry.mjs +6 -4
- package/src/tools/config.mjs +31 -0
- package/src/tools/files.mjs +4 -4
- package/src/tools/stats.mjs +4 -4
- package/src/util.mjs +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.20
|
|
4
|
+
|
|
5
|
+
- `set_config_value`: a model may change this runtime’s own preferences (telemetry opt-out, read
|
|
6
|
+
and buffer line limits, result size) while it is running; the change applies immediately and is
|
|
7
|
+
saved to runtime.json. Access roots, blocked commands, the command guardrail, the shell and the
|
|
8
|
+
write limit stay with the person at this computer and are refused by the tool.
|
|
9
|
+
|
|
3
10
|
## 0.2.16
|
|
4
11
|
|
|
5
12
|
- Mark cursor-consuming `read_process_output` as non-idempotent so clients do not assume retries
|
package/package.json
CHANGED
package/src/catalog.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { searchToolHandlers } from './tools/search.mjs';
|
|
|
3
3
|
import { terminalToolHandlers } from './tools/terminal.mjs';
|
|
4
4
|
import { systemToolHandlers } from './tools/system.mjs';
|
|
5
5
|
import { statsToolHandlers } from './tools/stats.mjs';
|
|
6
|
+
import { configToolHandlers } from './tools/config.mjs';
|
|
6
7
|
|
|
7
8
|
// Every tool answers with a text result (image tools add an image part as well), so the
|
|
8
9
|
// declared output schema is the same shape everywhere and clients can rely on it.
|
|
@@ -737,6 +738,23 @@ export const toolDefinitions = [
|
|
|
737
738
|
annotations: readOnly,
|
|
738
739
|
handler: statsToolHandlers.get_runtime_stats,
|
|
739
740
|
},
|
|
741
|
+
{
|
|
742
|
+
name: 'set_config_value',
|
|
743
|
+
title: 'Change runtime setting',
|
|
744
|
+
description: 'Change one of this runtime’s own preferences on this computer: telemetryEnabled, maxReadLines, maxBufferedLines or maxOutputBytes. The change applies immediately and is saved to runtime.json. Access roots, blocked commands, the command guardrail, the shell and the write limit cannot be set through MCP — they stay with the person at this computer.',
|
|
745
|
+
inputSchema: {
|
|
746
|
+
type: 'object',
|
|
747
|
+
properties: {
|
|
748
|
+
key: { type: 'string', description: 'telemetryEnabled (true/false), maxReadLines, maxBufferedLines or maxOutputBytes.' },
|
|
749
|
+
value: { description: 'New value: a boolean for telemetryEnabled, a number for the limits.' },
|
|
750
|
+
},
|
|
751
|
+
required: ['key', 'value'],
|
|
752
|
+
additionalProperties: false,
|
|
753
|
+
},
|
|
754
|
+
// Preference only, and the same call twice leaves the same state.
|
|
755
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
756
|
+
handler: configToolHandlers.set_config_value,
|
|
757
|
+
},
|
|
740
758
|
];
|
|
741
759
|
|
|
742
760
|
export function advertisedTools() {
|
package/src/config.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
4
|
import { expandHome } from './util.mjs';
|
|
5
5
|
|
|
6
6
|
const configDir = process.env.REMCP_RUNTIME_CONFIG_DIR || path.join(os.homedir(), '.config', 'remcp');
|
|
@@ -97,6 +97,61 @@ export const runtimeConfig = Object.freeze({
|
|
|
97
97
|
telemetryEnabled,
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
// --- settings a model may change, and nothing else ------------------------------------------------
|
|
101
|
+
//
|
|
102
|
+
// Desktop Commander lets a model rewrite any of its own configuration, including the directories it
|
|
103
|
+
// may touch and the commands it must refuse. On ReMCP those two decide what the computer exposes, so
|
|
104
|
+
// they stay with the person at the computer: `allowedRoots`, `blockedCommands`, `dangerousCommands`
|
|
105
|
+
// (the command guardrail), `defaultShell`, `maxWriteBytes` and `name` are not settable from a tool.
|
|
106
|
+
//
|
|
107
|
+
// What is settable is a preference and two context limits — telemetry opt-out, the read and buffer
|
|
108
|
+
// line limits, and the result size — all of which the person can also change in runtime.json. The
|
|
109
|
+
// values apply immediately (the tool handlers read them through liveConfig) and are written back to
|
|
110
|
+
// runtime.json so they survive a restart.
|
|
111
|
+
const SETTABLE = Object.freeze({
|
|
112
|
+
telemetryEnabled: { type: 'boolean' },
|
|
113
|
+
maxReadLines: { type: 'integer', min: 1, max: 100_000 },
|
|
114
|
+
maxBufferedLines: { type: 'integer', min: 1, max: 1_000_000 },
|
|
115
|
+
maxOutputBytes: { type: 'integer', min: 1024, max: HARD_OUTPUT_CEILING_BYTES },
|
|
116
|
+
});
|
|
117
|
+
export const settableKeys = Object.freeze(Object.keys(SETTABLE));
|
|
118
|
+
|
|
119
|
+
const live = new Map();
|
|
120
|
+
|
|
121
|
+
// Every read of a settable value goes through here, so a change applies to the next call.
|
|
122
|
+
export function liveConfig(key) {
|
|
123
|
+
return live.has(key) ? live.get(key) : runtimeConfig[key];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function validateConfigValue(key, raw) {
|
|
127
|
+
const rule = SETTABLE[key];
|
|
128
|
+
if (!rule) {
|
|
129
|
+
throw new Error(`Unsupported setting: ${key || '(empty)'}. Settable here: ${settableKeys.join(', ')}. Access roots, blocked commands, the command guardrail, the shell and write limits are changed by the person at this computer.`);
|
|
130
|
+
}
|
|
131
|
+
if (rule.type === 'boolean') {
|
|
132
|
+
if (typeof raw === 'boolean') return raw;
|
|
133
|
+
if (typeof raw === 'string') return booleanValue(raw, null) ?? (() => { throw new Error(`${key} must be true or false`); })();
|
|
134
|
+
throw new Error(`${key} must be true or false`);
|
|
135
|
+
}
|
|
136
|
+
const value = Math.trunc(Number(raw));
|
|
137
|
+
if (!Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
138
|
+
if (value < rule.min || value > rule.max) throw new Error(`${key} must be between ${rule.min} and ${rule.max}`);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function applyLiveConfig(key, value) {
|
|
143
|
+
live.set(key, value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Writes only the changed key back, with the file mode the rest of the runtime expects.
|
|
147
|
+
export function persistConfigValue(key, value) {
|
|
148
|
+
const file = readConfigFile();
|
|
149
|
+
const next = { ...file, [key]: value };
|
|
150
|
+
mkdirSync(runtimeConfigDir, { recursive: true, mode: 0o700 });
|
|
151
|
+
writeFileSync(runtimeConfigPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
152
|
+
return runtimeConfigPath;
|
|
153
|
+
}
|
|
154
|
+
|
|
100
155
|
// A configuration the user cannot read is not a configuration we should quietly ignore:
|
|
101
156
|
// it is how allowedRoots and an opt-out silently disappear.
|
|
102
157
|
export function configurationError() {
|
package/src/sessions.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
|
-
import { runtimeConfig } from './config.mjs';
|
|
2
|
+
import { liveConfig, runtimeConfig } from './config.mjs';
|
|
3
3
|
|
|
4
4
|
const processSessions = new Map();
|
|
5
5
|
const searchSessions = new Map();
|
|
@@ -13,7 +13,7 @@ const MAX_PARTIAL_BYTES = 64 * 1024;
|
|
|
13
13
|
const MAX_BUFFERED_CHARS = 8 * 1024 * 1024;
|
|
14
14
|
|
|
15
15
|
function trimBuffer(session) {
|
|
16
|
-
const overflow = session.lines.length -
|
|
16
|
+
const overflow = session.lines.length - liveConfig('maxBufferedLines');
|
|
17
17
|
if (overflow > 0) {
|
|
18
18
|
const removed = session.lines.splice(0, overflow);
|
|
19
19
|
for (const line of removed) session.bufferedChars -= line.length;
|
package/src/telemetry.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
|
-
import { runtimeConfig } from './config.mjs';
|
|
2
|
+
import { liveConfig, runtimeConfig } from './config.mjs';
|
|
3
3
|
import { VERSION } from './version.mjs';
|
|
4
4
|
|
|
5
5
|
// Event fields are whitelisted: an event can never carry a file path, a command
|
|
@@ -20,7 +20,7 @@ const FLUSH_INTERVAL_MS = 15_000;
|
|
|
20
20
|
const FLUSH_THRESHOLD = 20;
|
|
21
21
|
|
|
22
22
|
const state = {
|
|
23
|
-
enabled:
|
|
23
|
+
enabled: liveConfig('telemetryEnabled'),
|
|
24
24
|
buffer: [],
|
|
25
25
|
sink: null,
|
|
26
26
|
timer: null,
|
|
@@ -39,8 +39,10 @@ const state = {
|
|
|
39
39
|
toolCounts: new Map(),
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
// Live: a runtime setting changed through set_config_value applies to the next call, not the next
|
|
43
|
+
// restart.
|
|
42
44
|
export function telemetryEnabled() {
|
|
43
|
-
return
|
|
45
|
+
return liveConfig('telemetryEnabled');
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export function telemetryStatus() {
|
|
@@ -97,7 +99,7 @@ export function recordEvent(event, detail = {}) {
|
|
|
97
99
|
if (name === 'policy_block') state.counters.policyBlocks += 1;
|
|
98
100
|
if (name === 'session_started') state.counters.sessionsStarted += 1;
|
|
99
101
|
if (name === 'write_denied') state.counters.writeDenials += 1;
|
|
100
|
-
if (!
|
|
102
|
+
if (!telemetryEnabled()) return;
|
|
101
103
|
if (state.buffer.length >= BUFFER_LIMIT) {
|
|
102
104
|
state.dropped += 1;
|
|
103
105
|
return;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { applyLiveConfig, liveConfig, persistConfigValue, runtimeConfig, settableKeys, validateConfigValue } from '../config.mjs';
|
|
2
|
+
import { text } from '../util.mjs';
|
|
3
|
+
|
|
4
|
+
// The one write into this runtime's own configuration, and a deliberately narrow one.
|
|
5
|
+
//
|
|
6
|
+
// Desktop Commander exposes its whole config object to the model, including the directories it may
|
|
7
|
+
// touch and the commands it must refuse. Those two decide what this computer exposes, so they stay
|
|
8
|
+
// with the person at the computer: only the telemetry opt-out and the read/buffer/output limits can be
|
|
9
|
+
// changed here, and the change is written back to runtime.json so it survives a restart.
|
|
10
|
+
export async function setConfigValueTool(args = {}) {
|
|
11
|
+
const key = String(args.key || '').trim();
|
|
12
|
+
const value = validateConfigValue(key, args.value);
|
|
13
|
+
applyLiveConfig(key, value);
|
|
14
|
+
const file = persistConfigValue(key, value);
|
|
15
|
+
return text(JSON.stringify({
|
|
16
|
+
ok: true,
|
|
17
|
+
key,
|
|
18
|
+
value,
|
|
19
|
+
applied: 'immediately',
|
|
20
|
+
savedTo: file,
|
|
21
|
+
note: `Effective now and after a restart. Settable through MCP: ${settableKeys.join(', ')}. Access roots, blocked commands, the command guardrail, the shell and the write limit stay with the person at this computer.`,
|
|
22
|
+
effective: Object.fromEntries(settableKeys.map(name => [name, liveConfig(name)])),
|
|
23
|
+
}, null, 2));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const configToolHandlers = {
|
|
27
|
+
set_config_value: args => setConfigValueTool(args),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Kept so a future tool can report the settable surface without duplicating the list.
|
|
31
|
+
export const configSurface = Object.freeze({ settableKeys, roots: runtimeConfig.allowedRoots });
|
package/src/tools/files.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto';
|
|
|
5
5
|
import { constants, createReadStream } from 'node:fs';
|
|
6
6
|
import { access, chmod, chown, copyFile, cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
7
7
|
import { pipeline } from 'node:stream/promises';
|
|
8
|
-
import { runtimeConfig } from '../config.mjs';
|
|
8
|
+
import { liveConfig, runtimeConfig } from '../config.mjs';
|
|
9
9
|
import { diffStats, unifiedDiff } from '../diff.mjs';
|
|
10
10
|
import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
|
|
11
11
|
import { countEvent, recordEvent } from '../telemetry.mjs';
|
|
@@ -97,7 +97,7 @@ export async function readFileTool(args) {
|
|
|
97
97
|
const { content, encoding, eol } = await readTextFile(absolute);
|
|
98
98
|
const lines = splitLines(content);
|
|
99
99
|
const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
|
|
100
|
-
const length = clampInteger(args.length,
|
|
100
|
+
const length = clampInteger(args.length, liveConfig('maxReadLines'), 1, 10000);
|
|
101
101
|
const { start, end, slice } = pageLines(lines, offset, length);
|
|
102
102
|
const notes = `${encoding === 'utf8' ? '' : ` ${encoding}`}${eol === '\r\n' ? ' CRLF' : ''}`;
|
|
103
103
|
const header = lines.length
|
|
@@ -121,7 +121,7 @@ export async function readMultipleFilesTool(args) {
|
|
|
121
121
|
try {
|
|
122
122
|
const { content } = await readTextFile(absolute);
|
|
123
123
|
const lines = splitLines(content);
|
|
124
|
-
const limit =
|
|
124
|
+
const limit = liveConfig('maxReadLines');
|
|
125
125
|
const slice = lines.slice(0, limit);
|
|
126
126
|
const suffix = lines.length > limit ? `\n… ${lines.length - limit} more lines truncated` : '';
|
|
127
127
|
sections.push(`${displayPath(absolute)}:\n${slice.join('\n')}${suffix}`);
|
|
@@ -506,7 +506,7 @@ export async function readFilesTool(args) {
|
|
|
506
506
|
const root = await resolveSafePath(args.path || '.');
|
|
507
507
|
const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? args.pattern.trim() : '**/*';
|
|
508
508
|
const maxFiles = clampInteger(args.max_files, 100, 1, 500);
|
|
509
|
-
const maxLinesPerFile = clampInteger(args.max_lines_per_file,
|
|
509
|
+
const maxLinesPerFile = clampInteger(args.max_lines_per_file, liveConfig('maxReadLines'), 1, 20000);
|
|
510
510
|
const includeIgnored = args.include_ignored === true;
|
|
511
511
|
const matcher = globToRegExp(pattern);
|
|
512
512
|
const rootInfo = await stat(root).catch(() => null);
|
package/src/tools/stats.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
|
-
import { describeConfig, runtimeConfig } from '../config.mjs';
|
|
2
|
+
import { describeConfig, liveConfig, runtimeConfig } from '../config.mjs';
|
|
3
3
|
import { dangerousPatternIds } from '../policy.mjs';
|
|
4
4
|
import { listProcessSessions, listSearchSessions } from '../sessions.mjs';
|
|
5
5
|
import { telemetryStatus } from '../telemetry.mjs';
|
|
@@ -29,9 +29,9 @@ export async function getRuntimeInfoTool() {
|
|
|
29
29
|
remoteFeatureFlags: telemetry.remoteFeatureFlags,
|
|
30
30
|
},
|
|
31
31
|
limits: {
|
|
32
|
-
maxOutputBytes:
|
|
33
|
-
maxReadLines:
|
|
34
|
-
maxBufferedLines:
|
|
32
|
+
maxOutputBytes: liveConfig('maxOutputBytes'),
|
|
33
|
+
maxReadLines: liveConfig('maxReadLines'),
|
|
34
|
+
maxBufferedLines: liveConfig('maxBufferedLines'),
|
|
35
35
|
maxWriteBytes: runtimeConfig.maxWriteBytes,
|
|
36
36
|
maxConcurrentConnections: 1,
|
|
37
37
|
},
|
package/src/util.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { realpath } from 'node:fs/promises';
|
|
4
|
-
import { runtimeConfig } from './config.mjs';
|
|
4
|
+
import { liveConfig, runtimeConfig } from './config.mjs';
|
|
5
5
|
|
|
6
6
|
export class ToolError extends Error {}
|
|
7
7
|
|
|
@@ -111,7 +111,7 @@ function frameBytes(text) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
export function truncate(text, maxBytes) {
|
|
114
|
-
const limit = maxBytes ||
|
|
114
|
+
const limit = maxBytes || liveConfig('maxOutputBytes');
|
|
115
115
|
const value = String(text);
|
|
116
116
|
if (Buffer.byteLength(value, 'utf8') <= limit && frameBytes(value) <= limit) return value;
|
|
117
117
|
// Shrink until the escaped frame fits, so the escaped size is what the caller gets is bounded.
|
|
@@ -191,7 +191,7 @@ export function pageLines(lines, offset, length) {
|
|
|
191
191
|
return { start: total - count, end: total, slice: lines.slice(total - count) };
|
|
192
192
|
}
|
|
193
193
|
const start = Math.min(requested, total);
|
|
194
|
-
const end = Math.min(start + Math.max(1, Math.trunc(length ||
|
|
194
|
+
const end = Math.min(start + Math.max(1, Math.trunc(length || liveConfig('maxReadLines'))), total);
|
|
195
195
|
return { start, end, slice: lines.slice(start, end) };
|
|
196
196
|
}
|
|
197
197
|
|