@remcp/runtime 0.2.47 → 0.2.49

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/src/mcp-v2.mjs ADDED
@@ -0,0 +1,104 @@
1
+ import process from 'node:process';
2
+ import { toolDefinitions, TEXT_OUTPUT_SCHEMA } from './catalog.mjs';
3
+ import { advertisedExtendedTools, extendedToolDefinitions } from './extended/catalog.mjs';
4
+ import { invokeTool } from './invoke.mjs';
5
+
6
+ const DEFAULT_CAPABILITY_POLL_MS = 3000;
7
+
8
+ function pollIntervalMs() {
9
+ const requested = Number(process.env.REMCP_CAPABILITY_POLL_MS);
10
+ return Math.min(60_000, Math.max(250, Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_CAPABILITY_POLL_MS));
11
+ }
12
+
13
+ function toolConfig(definition, fromJsonSchema) {
14
+ return {
15
+ title: definition.title,
16
+ description: definition.description,
17
+ inputSchema: fromJsonSchema(definition.inputSchema || { type: 'object' }),
18
+ outputSchema: fromJsonSchema(definition.outputSchema || TEXT_OUTPUT_SCHEMA),
19
+ annotations: definition.annotations,
20
+ };
21
+ }
22
+
23
+ export async function startRuntimeMcpServer({ version, instructions, onError = () => {} } = {}) {
24
+ const [{ McpServer, fromJsonSchema }, { serveStdio }] = await Promise.all([
25
+ import('@modelcontextprotocol/server'),
26
+ import('@modelcontextprotocol/server/stdio'),
27
+ ]);
28
+
29
+ const initiallySupported = new Set((await advertisedExtendedTools()).map(tool => tool.name));
30
+ let activeServer = null;
31
+ let capabilityTimer = null;
32
+ let closed = false;
33
+
34
+ const serverHandle = serveStdio(() => {
35
+ const server = new McpServer(
36
+ { name: 'remcp-runtime', version },
37
+ {
38
+ capabilities: {
39
+ tools: { listChanged: true },
40
+ experimental: { 'remcp/telemetry': { version: 1, optOut: true } },
41
+ },
42
+ instructions,
43
+ cacheHints: {
44
+ 'tools/list': { ttlMs: 1500, cacheScope: 'private' },
45
+ 'server/discover': { ttlMs: 3000, cacheScope: 'private' },
46
+ },
47
+ },
48
+ );
49
+ activeServer = server;
50
+
51
+ const registrations = new Map();
52
+ for (const definition of [...toolDefinitions, ...extendedToolDefinitions]) {
53
+ const registered = server.registerTool(
54
+ definition.name,
55
+ toolConfig(definition, fromJsonSchema),
56
+ async (args, context) => invokeTool(definition.name, args || {}, { signal: context?.mcpReq?.signal }),
57
+ );
58
+ registrations.set(definition.name, registered);
59
+ if (extendedToolDefinitions.includes(definition) && !initiallySupported.has(definition.name)) registered.disable();
60
+ }
61
+
62
+ let lastSupported = initiallySupported;
63
+ capabilityTimer = setInterval(async () => {
64
+ if (closed) return;
65
+ try {
66
+ const next = new Set((await advertisedExtendedTools()).map(tool => tool.name));
67
+ for (const definition of extendedToolDefinitions) {
68
+ const before = lastSupported.has(definition.name);
69
+ const after = next.has(definition.name);
70
+ if (before === after) continue;
71
+ const registered = registrations.get(definition.name);
72
+ if (after) registered?.enable();
73
+ else registered?.disable();
74
+ }
75
+ lastSupported = next;
76
+ } catch (error) {
77
+ onError(error instanceof Error ? error : new Error(String(error)));
78
+ }
79
+ }, pollIntervalMs());
80
+ capabilityTimer.unref?.();
81
+
82
+ return server;
83
+ }, {
84
+ legacy: 'serve',
85
+ onerror(error) {
86
+ onError(error instanceof Error ? error : new Error(String(error)));
87
+ },
88
+ });
89
+
90
+ return {
91
+ async notification(notification) {
92
+ if (!activeServer) return;
93
+ await activeServer.server.notification(notification);
94
+ },
95
+ async close() {
96
+ if (closed) return;
97
+ closed = true;
98
+ if (capabilityTimer) clearInterval(capabilityTimer);
99
+ capabilityTimer = null;
100
+ await serverHandle.close();
101
+ activeServer = null;
102
+ },
103
+ };
104
+ }
@@ -75,7 +75,7 @@ export function filesystemErrorExplanation(error, options = {}) {
75
75
  return `Windows Controlled folder access is blocking ${windowsLocation} (${code}). Add the binary that runs the tools (${execPath}) under Windows Security → Virus & threat protection → Ransomware protection → Allow an app through Controlled folder access, then restart the agent with \`remcp start\`. A folder outside Desktop, Documents, Downloads and OneDrive needs no new permission.`;
76
76
  }
77
77
  if (platform === 'darwin') {
78
- return `macOS denied access to ${where} (${code}). Check the ownership and permissions of that folder, or add ${execPath} to System Settings → Privacy & Security → Full Disk Access and restart the agent with \`remcp start\`.`;
78
+ return `macOS denied access to ${where} (${code}). Check that this user owns the folder and its parents, or choose another path. If normal permissions are correct and macOS privacy still blocks it, add ${execPath} to System Settings → Privacy & Security → Full Disk Access and restart the agent with \`remcp start\`.`;
79
79
  }
80
80
  if (platform === 'win32') {
81
81
  return `Windows denied access to ${where} (${code}). Check whether the file is read-only, open in another program, or inside a Controlled folder access area, then allow ${execPath} through Windows Security (Controlled folder access) and restart the agent with \`remcp start\`.`;
@@ -17,7 +17,7 @@ import {
17
17
  waitForProcessExit,
18
18
  waitForProcessActivity,
19
19
  } from '../sessions.mjs';
20
- import { clampInteger, fail, requireInteger, requireString, text } from '../util.mjs';
20
+ import { clampInteger, fail, requireInteger, requireString, structured, text } from '../util.mjs';
21
21
 
22
22
  function shellCommand() {
23
23
  if (runtimeConfig.defaultShell) return runtimeConfig.defaultShell;
@@ -32,7 +32,15 @@ function shellArgs(command) {
32
32
 
33
33
  function describeSession(session) {
34
34
  const status = session.exited ? `exited${session.signal ? ` (${session.signal})` : session.exitCode === null ? '' : ` (code ${session.exitCode})`}` : 'running';
35
- return { pid: session.pid, status, runtimeMs: (session.finishedAt || Date.now()) - session.startedAt, lines: totalLines(session) };
35
+ return {
36
+ pid: session.pid,
37
+ status,
38
+ runtimeMs: (session.finishedAt || Date.now()) - session.startedAt,
39
+ lines: totalLines(session),
40
+ exited: Boolean(session.exited),
41
+ exitCode: session.exitCode ?? null,
42
+ signal: session.signal ?? null,
43
+ };
36
44
  }
37
45
 
38
46
  function abortError() {
@@ -90,8 +98,16 @@ export async function startProcessTool(args, extra = {}) {
90
98
  const partial = session.partial;
91
99
  session.cursor = session.droppedLines + session.lines.length;
92
100
  session.lastPartialRead = session.partial || null;
93
- const warning = verdict.note ? `${verdict.note}\n` : '';
94
- return text(`${warning}${[headline, output, partial].filter(Boolean).join('\n')}`);
101
+ const warning = verdict.note ? String(verdict.note) : '';
102
+ const rendered = [warning, headline, output, partial].filter(Boolean).join('\n');
103
+ const status = describeSession(session);
104
+ return structured({
105
+ ...status,
106
+ command,
107
+ output,
108
+ ...(partial ? { partial } : {}),
109
+ ...(warning ? { warning } : {}),
110
+ }, rendered);
95
111
  }
96
112
 
97
113
  export async function readProcessOutputTool(args, extra = {}) {
@@ -124,7 +140,13 @@ export async function readProcessOutputTool(args, extra = {}) {
124
140
  }
125
141
  const status = describeSession(session);
126
142
  const header = `pid ${pid} ${status.status} · lines ${range}`;
127
- return text(`${header}\n${slice.join('\n')}`);
143
+ const output = slice.join('\n');
144
+ return structured({
145
+ ...status,
146
+ range,
147
+ output,
148
+ explicitOffset:hasOffset,
149
+ }, `${header}\n${output}`);
128
150
  }
129
151
 
130
152
  function compileWaiter(pattern) {
@@ -164,7 +186,15 @@ export async function waitForProcessOutputTool(args, extra = {}) {
164
186
  const headline = matched
165
187
  ? `pid ${pid} ${status.status} · pattern matched${bufferedMatch ? ' (already buffered)' : ''}`
166
188
  : `pid ${pid} ${status.status} · pattern not matched within ${timeoutMs}ms`;
167
- return text([headline, slice.join('\n')].filter(Boolean).join('\n'));
189
+ const output = slice.join('\n');
190
+ return structured({
191
+ ...status,
192
+ pattern,
193
+ matched,
194
+ bufferedMatch,
195
+ timeoutMs,
196
+ output,
197
+ }, [headline, output].filter(Boolean).join('\n'));
168
198
  }
169
199
 
170
200
  export async function interactWithProcessTool(args, extra = {}) {
@@ -195,34 +225,49 @@ export async function interactWithProcessTool(args, extra = {}) {
195
225
  }
196
226
  const slice = readNewOutput(session);
197
227
  const status = describeSession(session);
198
- return text([`pid ${pid} ${status.status}`, slice.join('\n')].filter(Boolean).join('\n'));
228
+ const output = slice.join('\n');
229
+ return structured({
230
+ ...status,
231
+ output,
232
+ }, [`pid ${pid} ${status.status}`, output].filter(Boolean).join('\n'));
199
233
  }
200
234
 
201
235
  export async function forceTerminateTool(args) {
202
236
  const pid = requireInteger(args.pid, 'pid');
203
237
  const session = getProcessSession(pid);
204
238
  if (!session) fail(`No ReMCP session with pid ${pid}`);
205
- if (session.exited) return text(`Process ${pid} already exited.`);
239
+ if (session.exited) {
240
+ const status = describeSession(session);
241
+ return structured({ ...status, terminated:false, alreadyExited:true, escalated:false }, `Process ${pid} already exited.`);
242
+ }
206
243
  killSessionTree(session, 'SIGTERM');
207
244
  const deadline = Date.now() + 2000;
208
245
  while (!session.exited && Date.now() < deadline) await waitForProcessActivity(session, 100);
246
+ let escalated = false;
209
247
  if (!session.exited) {
248
+ escalated = true;
210
249
  killSessionTree(session, 'SIGKILL');
211
250
  await waitForProcessActivity(session, 1000);
212
251
  }
213
252
  const status = describeSession(session);
214
- return text(`Terminated session ${pid}${status.status.startsWith('exited') ? '' : ' (still running)'}. Status: ${status.status}.`);
253
+ return structured(
254
+ { ...status, terminated:Boolean(session.exited), alreadyExited:false, escalated },
255
+ `Terminated session ${pid}${status.status.startsWith('exited') ? '' : ' (still running)'}. Status: ${status.status}.`,
256
+ );
215
257
  }
216
258
 
217
259
  export async function listSessionsTool() {
218
260
  const sessions = listProcessSessions();
219
- if (!sessions.length) return text('No active terminal sessions.');
220
- const rows = sessions.map(session => {
261
+ const facts = sessions.map(session => {
221
262
  const status = describeSession(session);
222
263
  const blocked = session.exited ? '' : session.partial ? 'blocked-possibly' : 'idle-or-running';
223
- return `pid ${status.pid} · ${status.status} · ${Math.round(status.runtimeMs / 1000)}s · ${blocked} · ${session.command.slice(0, 120)}`;
264
+ return { ...status, blocked, command:session.command.slice(0, 120) };
224
265
  });
225
- return text(rows.join('\n'));
266
+ if (!facts.length) return structured({ sessions:[] }, 'No active terminal sessions.');
267
+ const rows = facts.map(session =>
268
+ `pid ${session.pid} · ${session.status} · ${Math.round(session.runtimeMs / 1000)}s · ${session.blocked} · ${session.command}`
269
+ );
270
+ return structured({ sessions:facts }, rows.join('\n'));
226
271
  }
227
272
 
228
273
  export const terminalToolHandlers = {
package/src/util.mjs CHANGED
@@ -88,8 +88,16 @@ function resolvedRoots() {
88
88
  // Canonical, allowlist-checked path for every file tool. The canonical path is what
89
89
  // callers must use, so a symlink cannot be swapped between the check and the access.
90
90
  export async function resolveSafePath(value, field = 'path') {
91
- const absolute = resolveInputPath(value, field);
91
+ const raw = expandHome(requireString(value, field));
92
+ if (raw.includes('\0')) fail(`${field} contains an invalid character`);
93
+ const absolute = path.resolve(raw);
92
94
  if (!runtimeConfig.allowedRoots.length) return absolute;
95
+
96
+ // Canonicalize both sides before enforcing confinement. This matters on macOS where
97
+ // /var is a symlink to /private/var: a safe path returned by an earlier canonicalization
98
+ // must not be rejected merely because its lexical prefix differs from the configured root.
99
+ // Symlink escapes remain blocked because the final canonical target must still be inside
100
+ // one of the canonical allowed roots.
93
101
  const canonical = await canonicalizePath(absolute);
94
102
  const roots = await resolvedRoots();
95
103
  if (!roots.some(root => isInsideRoot(canonical, root))) {
@@ -145,6 +153,21 @@ export function text(value, isError = false) {
145
153
  return { content: [{ type: 'text', text: rendered }], structuredContent: { text: mirror }, ...(isError ? { isError: true } : {}) };
146
154
  }
147
155
 
156
+ export function structured(value, fallbackText = null) {
157
+ const serialized = JSON.stringify(value);
158
+ const bytes = Buffer.byteLength(serialized ?? 'null', 'utf8');
159
+ const rendered = truncate(fallbackText == null ? JSON.stringify(value, null, 2) : String(fallbackText));
160
+ let structuredContent;
161
+ if (bytes > STRUCTURED_MIRROR_LIMIT_BYTES) {
162
+ structuredContent = { truncated: true, bytes, preview: rendered.slice(0, 2048) };
163
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
164
+ structuredContent = value;
165
+ } else {
166
+ structuredContent = { data: value };
167
+ }
168
+ return { content: [{ type: 'text', text: rendered }], structuredContent };
169
+ }
170
+
148
171
  export function image(data, mimeType) {
149
172
  return { type: 'image', data, mimeType };
150
173
  }