@remcp/runtime 0.2.57 → 0.2.58

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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.58 — 2026-09-20
6
+
7
+ - `computer_snapshot` normalizes partial platform-capture failures into schema-stable collections/objects and bounded `{ source, message }` errors instead of leaking error strings into structured output.
8
+ - Output-budget pruning preserves the structured snapshot error contract, and browser snapshot fallbacks reject invalid/error-shaped payloads.
9
+ - Dynamic browser/CDP capability groups now toggle registrations in one batch and emit one `tools/list_changed` notification per transition, preventing concurrent stdio notification writes from exceeding Node's listener limit.
10
+ - Lockstep patch release with the ReMCP client at `0.2.58`.
11
+
5
12
  ## 0.2.57 — 2026-09-20
6
13
 
7
14
  - ZIP extraction recognizes Info-ZIP `unzip` on Debian/Ubuntu by probing it with `-v`, fixing the false “unzip is not installed” error reproduced during physical 76-tool package verification.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.57",
3
+ "version": "0.2.58",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -137,35 +137,78 @@ function resultValue(result) {
137
137
  try { return JSON.parse(raw); } catch { return raw; }
138
138
  }
139
139
 
140
+ function objectRecord(value) {
141
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
142
+ }
143
+
144
+ function toolResultMessage(result) {
145
+ const text = result?.content?.find(part => part.type === 'text')?.text;
146
+ return String(text || 'tool returned an error result').slice(0, 2000);
147
+ }
148
+
149
+ function snapshotError(source, settled) {
150
+ if (settled?.status === 'rejected') {
151
+ return { source, message:String(settled.reason?.message || settled.reason || 'capture rejected').slice(0, 2000) };
152
+ }
153
+ if (settled?.status === 'fulfilled' && settled.value?.isError === true) {
154
+ return { source, message:toolResultMessage(settled.value) };
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function normalizeSnapshotErrors(errors) {
160
+ if (!Array.isArray(errors)) return [];
161
+ return errors.slice(0, 50).map((error, index) => {
162
+ if (objectRecord(error)) {
163
+ return {
164
+ ...error,
165
+ source:String(error.source || 'snapshot').slice(0, 120),
166
+ message:String(error.message || error.error || 'snapshot capture error').slice(0, 2000),
167
+ };
168
+ }
169
+ return { source:'snapshot', message:String(error ?? `snapshot error ${index + 1}`).slice(0, 2000) };
170
+ });
171
+ }
172
+
140
173
  function jsonBytes(value) {
141
174
  return Buffer.byteLength(JSON.stringify(value), 'utf8');
142
175
  }
143
176
 
144
177
  export function boundedComputerSnapshot(payload) {
178
+ const normalized = {
179
+ ...payload,
180
+ active_window:objectRecord(payload?.active_window),
181
+ windows:Array.isArray(payload?.windows) ? payload.windows : [],
182
+ displays:Array.isArray(payload?.displays) ? payload.displays : [],
183
+ cursor:objectRecord(payload?.cursor) || { x:null, y:null },
184
+ ui:objectRecord(payload?.ui) || { platform:process.platform, count:0, nodes:[] },
185
+ clipboard:objectRecord(payload?.clipboard) || { available:false, length:0 },
186
+ errors:normalizeSnapshotErrors(payload?.errors),
187
+ };
145
188
  const budget = Math.min(768 * 1024, Math.max(16 * 1024, Math.floor(Number(liveConfig('maxOutputBytes') || 0) * 0.35)));
146
- const originalBytes = jsonBytes(payload);
189
+ const originalBytes = jsonBytes(normalized);
147
190
  if (originalBytes <= budget) {
148
- return { ...payload, snapshot_bytes:originalBytes, snapshot_budget_bytes:budget, snapshot_truncated:false };
191
+ return { ...normalized, snapshot_bytes:originalBytes, snapshot_budget_bytes:budget, snapshot_truncated:false };
149
192
  }
150
193
 
151
194
  const bounded = {
152
- ...payload,
153
- windows:Array.isArray(payload.windows) ? [...payload.windows] : payload.windows,
154
- errors:Array.isArray(payload.errors) ? payload.errors.map(value => String(value).slice(0, 1000)).slice(0, 50) : payload.errors,
155
- ui:payload.ui && typeof payload.ui === 'object' ? {
156
- ...payload.ui,
157
- ...(Array.isArray(payload.ui.nodes) ? { nodes:[...payload.ui.nodes] } : {}),
158
- } : payload.ui,
159
- browser:payload.browser && typeof payload.browser === 'object' ? {
160
- ...payload.browser,
161
- ...(Array.isArray(payload.browser.nodes) ? { nodes:[...payload.browser.nodes] } : {}),
162
- } : payload.browser,
163
- ocr:payload.ocr && typeof payload.ocr === 'object' ? {
164
- ...payload.ocr,
165
- text:String(payload.ocr.text || '').slice(0, 50_000),
166
- ...(Array.isArray(payload.ocr.words) ? { words:[...payload.ocr.words] } : {}),
167
- ...(Array.isArray(payload.ocr.lines) ? { lines:[...payload.ocr.lines] } : {}),
168
- } : payload.ocr,
195
+ ...normalized,
196
+ windows:[...normalized.windows],
197
+ errors:normalizeSnapshotErrors(normalized.errors),
198
+ ui:{
199
+ ...normalized.ui,
200
+ ...(Array.isArray(normalized.ui.nodes) ? { nodes:[...normalized.ui.nodes] } : {}),
201
+ },
202
+ browser:objectRecord(normalized.browser) ? {
203
+ ...normalized.browser,
204
+ ...(Array.isArray(normalized.browser.nodes) ? { nodes:[...normalized.browser.nodes] } : {}),
205
+ } : undefined,
206
+ ocr:objectRecord(normalized.ocr) ? {
207
+ ...normalized.ocr,
208
+ text:String(normalized.ocr.text || '').slice(0, 50_000),
209
+ ...(Array.isArray(normalized.ocr.words) ? { words:[...normalized.ocr.words] } : {}),
210
+ ...(Array.isArray(normalized.ocr.lines) ? { lines:[...normalized.ocr.lines] } : {}),
211
+ } : undefined,
169
212
  snapshot_original_bytes:originalBytes,
170
213
  snapshot_budget_bytes:budget,
171
214
  snapshot_truncated:true,
@@ -939,15 +982,33 @@ export async function computerSnapshot(args = {}) {
939
982
  cursorPosition(),
940
983
  captureScreenshot ? computerScreenshotTask(args) : Promise.resolve(null),
941
984
  ]);
942
- const parse = result => {
943
- if (result.status !== 'fulfilled' || !result.value) return null;
944
- const raw = result.value.content?.[0]?.text;
945
- if (typeof raw !== 'string') return null;
946
- try { return JSON.parse(raw); } catch { return raw; }
985
+ const parse = (result, fallback = null) => {
986
+ if (result.status !== 'fulfilled' || !result.value || result.value.isError === true) return fallback;
987
+ return resultValue(result.value) ?? fallback;
947
988
  };
948
- const clipText = clipboardResult.status === 'fulfilled' ? clipboardResult.value.content?.[0]?.text || '' : '';
949
- const windows = parse(windowsResult);
950
- const ui = parse(uiResult);
989
+ const shapeErrors = [];
990
+ const parsedWindows = parse(windowsResult, []);
991
+ const windows = Array.isArray(parsedWindows) ? parsedWindows : [];
992
+ if (parsedWindows !== windows && parsedWindows != null) {
993
+ shapeErrors.push({ source:'windows', message:'window inventory returned an invalid result shape' });
994
+ }
995
+ const parsedDisplays = parse(displaysResult, []);
996
+ const displays = Array.isArray(parsedDisplays) ? parsedDisplays : [];
997
+ if (parsedDisplays !== displays && parsedDisplays != null) {
998
+ shapeErrors.push({ source:'displays', message:'display inventory returned an invalid result shape' });
999
+ }
1000
+ const parsedCursor = parse(cursorResult, { x:null, y:null });
1001
+ const cursor = objectRecord(parsedCursor) || { x:null, y:null };
1002
+ if (parsedCursor !== cursor && parsedCursor != null) {
1003
+ shapeErrors.push({ source:'cursor', message:'cursor position returned an invalid result shape' });
1004
+ }
1005
+ const parsedUi = includeUi ? parse(uiResult, null) : null;
1006
+ const ui = objectRecord(parsedUi) || { platform:process.platform, count:0, nodes:[], ...(includeUi ? { unavailable:true } : { skipped:true }) };
1007
+ if (includeUi && parsedUi !== ui && parsedUi != null) {
1008
+ shapeErrors.push({ source:'ui', message:'accessibility snapshot returned an invalid result shape' });
1009
+ }
1010
+ const clipboardOk = clipboardResult.status === 'fulfilled' && clipboardResult.value?.isError !== true;
1011
+ const clipText = clipboardOk ? clipboardResult.value.content?.[0]?.text || '' : '';
951
1012
  const uiNodes = Array.isArray(ui?.nodes) ? ui.nodes : Array.isArray(ui) ? ui : [];
952
1013
  const focusedNodes = uiNodes.filter(node => node.focused === true);
953
1014
  const focusScore = node => {
@@ -1002,18 +1063,24 @@ export async function computerSnapshot(args = {}) {
1002
1063
  if (browserRequested && await browserCapabilityAvailable(undefined, 400)) {
1003
1064
  const hint = browserTitleHint(activeWindow);
1004
1065
  try {
1005
- browser = resultValue(await browserSnapshot({
1066
+ const browserResult = await browserSnapshot({
1006
1067
  ...(hint ? { title:hint } : {}),
1007
1068
  max_nodes:clamp(args.browser_max_nodes, 500, 1, 2000),
1008
1069
  timeout_ms:3000,
1009
- }));
1070
+ });
1071
+ if (browserResult?.isError === true) throw new Error(toolResultMessage(browserResult));
1072
+ browser = objectRecord(resultValue(browserResult));
1073
+ if (!browser) throw new Error('browser snapshot returned an invalid result shape');
1010
1074
  } catch (error) {
1011
1075
  if (hint) {
1012
1076
  try {
1013
- browser = resultValue(await browserSnapshot({
1077
+ const fallbackResult = await browserSnapshot({
1014
1078
  max_nodes:clamp(args.browser_max_nodes, 500, 1, 2000),
1015
1079
  timeout_ms:3000,
1016
- }));
1080
+ });
1081
+ if (fallbackResult?.isError === true) throw new Error(toolResultMessage(fallbackResult));
1082
+ browser = objectRecord(resultValue(fallbackResult));
1083
+ if (!browser) throw new Error('browser snapshot returned an invalid result shape');
1017
1084
  } catch (fallbackError) {
1018
1085
  browserError = String(fallbackError?.message || fallbackError);
1019
1086
  }
@@ -1058,8 +1125,8 @@ export async function computerSnapshot(args = {}) {
1058
1125
  active_app: activeApp,
1059
1126
  active_window: activeWindow,
1060
1127
  windows,
1061
- displays: parse(displaysResult),
1062
- cursor: parse(cursorResult),
1128
+ displays,
1129
+ cursor,
1063
1130
  ui,
1064
1131
  ...(browser ? { browser } : {}),
1065
1132
  ...(ocr ? { ocr } : {}),
@@ -1098,12 +1165,18 @@ export async function computerSnapshot(args = {}) {
1098
1165
  : (args.screenshot_monitor != null || args.screenshot_monitor_index != null) ? 'monitor'
1099
1166
  : 'desktop',
1100
1167
  },
1101
- clipboard: { available:clipboardResult.status === 'fulfilled', length:clipText.length },
1168
+ clipboard: { available:clipboardOk, length:clipText.length },
1102
1169
  errors: [
1103
- ...[windowsResult, displaysResult, uiResult, clipboardResult, cursorResult, shotResult]
1104
- .filter(result => result.status === 'rejected')
1105
- .map(result => String(result.reason?.message || result.reason)),
1106
- ...(browserError ? [`browser snapshot: ${browserError}`] : []),
1170
+ ...[
1171
+ ['windows', windowsResult],
1172
+ ['displays', displaysResult],
1173
+ ...(includeUi ? [['ui', uiResult]] : []),
1174
+ ['clipboard', clipboardResult],
1175
+ ['cursor', cursorResult],
1176
+ ...(captureScreenshot ? [['screenshot', shotResult]] : []),
1177
+ ].map(([source, result]) => snapshotError(source, result)).filter(Boolean),
1178
+ ...shapeErrors,
1179
+ ...(browserError ? [{ source:'browser', message:browserError.slice(0, 2000) }] : []),
1107
1180
  ],
1108
1181
  };
1109
1182
  const boundedPayload = boundedComputerSnapshot(payload);
package/src/mcp-v2.mjs CHANGED
@@ -64,15 +64,23 @@ export async function startRuntimeMcpServer({ version, instructions, onError = (
64
64
  if (closed) return;
65
65
  try {
66
66
  const next = new Set((await advertisedExtendedTools()).map(tool => tool.name));
67
+ let changed = false;
67
68
  for (const definition of extendedToolDefinitions) {
68
69
  const before = lastSupported.has(definition.name);
69
70
  const after = next.has(definition.name);
70
71
  if (before === after) continue;
71
72
  const registered = registrations.get(definition.name);
72
- if (after) registered?.enable();
73
- else registered?.disable();
73
+ if (!registered) continue;
74
+ // The SDK's enable()/disable() helper emits tools/list_changed immediately. Browser CDP
75
+ // appears as a seven-tool capability group, so calling those helpers in a loop creates
76
+ // seven concurrent stdio writes and can exceed Node's listener limit. Mutating the
77
+ // registration handle is otherwise identical to update({enabled}), then one notification
78
+ // below publishes the complete new toolset atomically.
79
+ registered.enabled = after;
80
+ changed = true;
74
81
  }
75
82
  lastSupported = next;
83
+ if (changed) server.sendToolListChanged();
76
84
  } catch (error) {
77
85
  onError(error instanceof Error ? error : new Error(String(error)));
78
86
  }