@hcu-lab.me/mcp-device 1.0.8 → 1.0.10

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 CHANGED
@@ -5,7 +5,7 @@ The gateway owns accounts, OAuth, device ownership, MCP tool contracts, routing,
5
5
  ## Install
6
6
 
7
7
  ```powershell
8
- npm install -g @hcu-lab.me/mcp-device@1.0.7
8
+ npm install -g @hcu-lab.me/mcp-device@1.0.10
9
9
  mcp-device login
10
10
  mcp-device install
11
11
  mcp-device status
@@ -2,6 +2,7 @@
2
2
  import { type RuntimeOwnerStatus } from './runtime-owner.js';
3
3
  export declare class MCPDevice {
4
4
  private isShuttingDown;
5
+ private bootNonce;
5
6
  private executionEngine;
6
7
  private gatewayChannel?;
7
8
  private gatewayProxyAgent?;
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from 'url';
3
+ import { randomUUID } from 'crypto';
3
4
  import os from 'os';
4
5
  import path from 'path';
5
6
  import { LocalExecutionEngine } from './execution-engine.js';
@@ -17,6 +18,7 @@ import { VERSION } from '../version.js';
17
18
  export class MCPDevice {
18
19
  constructor() {
19
20
  this.isShuttingDown = false;
21
+ this.bootNonce = '';
20
22
  this.executionEngine = new LocalExecutionEngine();
21
23
  this.setupShutdownHandlers();
22
24
  }
@@ -59,6 +61,7 @@ export class MCPDevice {
59
61
  });
60
62
  }
61
63
  async start(options = {}) {
64
+ this.bootNonce = randomUUID();
62
65
  try {
63
66
  console.log('🚀 Starting MCP Device...');
64
67
  if (process.env.DEBUG_MODE === 'true')
@@ -75,7 +78,8 @@ export class MCPDevice {
75
78
  await acquireRuntimeOwner(this.runtimeOwner, { confirmTakeover: options.confirmTakeover });
76
79
  await this.runtimeOwner.bootstrap();
77
80
  await bootstrapOfficialGatewayTrust(new GatewayDeviceConfigStore());
78
- await this.executionEngine.initialize();
81
+ // Remote mode uses direct in-process dispatch (tool-dispatcher.ts).
82
+ // Do not eagerly initialize LocalExecutionEngine, avoiding child dist/index.js process spawn.
79
83
  const gatewayStatus = new GatewayDeviceStatusStore();
80
84
  const storedGatewayStatus = await gatewayStatus.load();
81
85
  const gatewayConfig = await new GatewayDeviceConfigStore().load();
@@ -115,9 +119,14 @@ export class MCPDevice {
115
119
  proxyAgent: proxy.agent,
116
120
  securityProtocolFloor: gatewayConfig.securityProtocolFloor,
117
121
  appCaPem: gatewayConfig.appCaPem || undefined,
118
- adapter: new GatewayToolAdapter(this.executionEngine, { allowedRoots: gatewayConfig.allowedRoots }),
119
- runtimeState: () => this.executionEngine.getRuntimeState(),
120
- agentVersion: process.env.npm_package_version,
122
+ adapter: new GatewayToolAdapter(undefined, { allowedRoots: gatewayConfig.allowedRoots }),
123
+ // In remote direct-dispatch mode, the daemon is ready once the gateway channel & dispatch are established.
124
+ runtimeState: () => ({
125
+ runtime_ready: true,
126
+ runtime_reason: null,
127
+ execution_runtime_generation: `direct-in-process:${this.bootNonce}`
128
+ }),
129
+ agentVersion: VERSION || process.env.npm_package_version || '1.0.10',
121
130
  onUpdateRequest: async (targetVersion, { requestId }) => {
122
131
  const record = await identity.loadOrCreate();
123
132
  const prepared = await prepareDevicePackageUpdate({
@@ -58,6 +58,7 @@ export declare class GatewayDeviceChannel {
58
58
  private sendUpdateStatusFlushed;
59
59
  reportUpdateFailure(requestId: string, targetVersion: string, code: string, message: string): Promise<void>;
60
60
  private handleUpdateRequest;
61
+ private dispatchToolCall;
61
62
  private handleToolCall;
62
63
  private scheduleReconnect;
63
64
  private startHeartbeat;
@@ -282,7 +282,7 @@ export class GatewayDeviceChannel {
282
282
  throw new Error('Gateway tool_call arrived before authentication');
283
283
  if (String(message.connection_epoch) !== String(this.connectionEpoch))
284
284
  throw new Error('Gateway tool_call connection_epoch mismatch');
285
- await this.handleToolCall(message);
285
+ this.dispatchToolCall(message);
286
286
  }
287
287
  async logoutAccount(timeoutMs = 5000) {
288
288
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN || this.connectionEpoch === undefined || !this.authenticatedDeviceId) {
@@ -395,30 +395,45 @@ export class GatewayDeviceChannel {
395
395
  this.updateInFlight = undefined;
396
396
  }
397
397
  }
398
+ dispatchToolCall(message) {
399
+ void this.handleToolCall(message).catch(error => {
400
+ if (!this.shuttingDown)
401
+ this.fail(error instanceof Error ? error : new Error(String(error)));
402
+ });
403
+ }
398
404
  async handleToolCall(message) {
399
405
  if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
400
406
  throw new Error('Gateway tool_call arrived before authentication');
401
407
  const requestId = String(message.request_id || '');
402
408
  const tool = String(message.payload?.tool || '');
409
+ const deviceId = this.authenticatedDeviceId;
410
+ const connectionEpoch = this.connectionEpoch;
411
+ const socket = this.socket;
412
+ const connectionIsCurrent = () => Boolean(socket && socket.readyState === WebSocket.OPEN && this.socket === socket &&
413
+ this.authenticatedDeviceId === deviceId && String(this.connectionEpoch) === String(connectionEpoch));
403
414
  try {
404
415
  const result = await this.options.adapter.call(tool, message.payload?.arguments || {});
416
+ if (!connectionIsCurrent())
417
+ return;
405
418
  this.send({
406
419
  protocol_version: this.protocolVersion,
407
420
  type: 'tool_result',
408
421
  request_id: requestId,
409
- device_id: this.authenticatedDeviceId,
410
- connection_epoch: this.connectionEpoch,
422
+ device_id: deviceId,
423
+ connection_epoch: connectionEpoch,
411
424
  timestamp: Date.now(),
412
425
  payload: result
413
426
  });
414
427
  }
415
428
  catch (error) {
429
+ if (!connectionIsCurrent())
430
+ return;
416
431
  this.send({
417
432
  protocol_version: this.protocolVersion,
418
433
  type: 'tool_error',
419
434
  request_id: requestId,
420
- device_id: this.authenticatedDeviceId,
421
- connection_epoch: this.connectionEpoch,
435
+ device_id: deviceId,
436
+ connection_epoch: connectionEpoch,
422
437
  timestamp: Date.now(),
423
438
  payload: {
424
439
  message: String(error?.message || error),
@@ -1,15 +1,14 @@
1
- import { LocalExecutionEngine } from './execution-engine.js';
2
1
  export declare const GATEWAY_CAPABILITIES: readonly ["read_text_file", "write_file", "edit_file", "shell_execute", "start_process", "read_process_output", "interact_with_process", "terminate_process", "image_preview", "project_inspect"];
3
2
  export interface GatewayToolAdapterOptions {
4
3
  allowedRoots?: string[];
5
4
  pathValidator?: (requestedPath: string) => Promise<string>;
6
5
  }
7
6
  export declare class GatewayToolAdapter {
8
- private engine;
9
7
  private allowedRoots;
10
8
  private pathValidator;
11
9
  private canonicalRoots?;
12
- constructor(engine: LocalExecutionEngine, options?: GatewayToolAdapterOptions);
10
+ constructor(_unusedEngine?: any, options?: GatewayToolAdapterOptions);
11
+ private parseSessionPid;
13
12
  private guardPath;
14
13
  private imagePreview;
15
14
  call(tool: string, args?: any): Promise<any>;
@@ -3,10 +3,9 @@ import fs from 'fs/promises';
3
3
  import { promisify } from 'util';
4
4
  import path from 'path';
5
5
  import sharp from 'sharp';
6
- import { commandManager } from '../command-manager.js';
7
- import { configManager } from '../config-manager.js';
8
6
  import { validatePath } from '../tools/filesystem.js';
9
7
  import { inspectProjectOnDevice } from './project-inspection.js';
8
+ import { dispatchToolCall } from '../tool-dispatcher.js';
10
9
  const execAsync = promisify(exec);
11
10
  const execFileAsync = promisify(execFile);
12
11
  const MAX_REMOTE_IMAGE_SOURCE_BYTES = 32 * 1024 * 1024;
@@ -30,57 +29,56 @@ export const GATEWAY_CAPABILITIES = [
30
29
  'project_inspect'
31
30
  ];
32
31
  function configuredGatewayRoots() {
33
- const raw = String(process.env.MCP_GATEWAY_ALLOWED_ROOTS || '').trim();
34
- if (!raw)
32
+ const raw = process.env.MCP_GATEWAY_ALLOWED_ROOTS || '';
33
+ if (!raw.trim())
35
34
  return [];
36
- let parsed;
37
- try {
38
- parsed = JSON.parse(raw);
39
- }
40
- catch {
41
- throw new Error('MCP_GATEWAY_ALLOWED_ROOTS must be a JSON array of absolute paths');
42
- }
43
- if (!Array.isArray(parsed))
44
- throw new Error('MCP_GATEWAY_ALLOWED_ROOTS must be a JSON array of absolute paths');
45
- return [...new Set(parsed.map(value => String(value).trim()).filter(Boolean))];
35
+ return raw
36
+ .split(path.delimiter)
37
+ .map(entry => entry.trim())
38
+ .filter(Boolean);
46
39
  }
47
40
  function isWithinRoot(candidate, root) {
48
41
  const relative = path.relative(root, candidate);
49
42
  return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
50
43
  }
51
- function textFromResult(result) {
52
- return (result?.content || [])
53
- .filter((item) => item?.type === 'text')
54
- .map((item) => String(item.text || ''))
55
- .join('\n');
44
+ function parsePid(result) {
45
+ if (!result || typeof result !== 'object')
46
+ return null;
47
+ const directPid = Number(result.pid);
48
+ if (Number.isInteger(directPid) && directPid > 0)
49
+ return directPid;
50
+ for (const item of result.content || []) {
51
+ if (typeof item?.text !== 'string')
52
+ continue;
53
+ const match = item.text.match(/Process started with PID (\d+)/i)
54
+ || item.text.match(/"pid"\s*:\s*(\d+)/i)
55
+ || item.text.match(/\bPID\s*[:=]?\s*(\d+)\b/i);
56
+ if (match)
57
+ return Number(match[1]);
58
+ }
59
+ return null;
56
60
  }
57
- function assertSuccess(result, tool) {
58
- if (result?.isError)
59
- throw new Error(textFromResult(result) || `${tool} failed`);
61
+ function assertSuccess(result, toolName) {
62
+ if (result && result.isError) {
63
+ const message = (result.content || [])
64
+ .map((item) => item?.text)
65
+ .filter(Boolean)
66
+ .join('\n') || `Tool execution failed: ${toolName}`;
67
+ const error = new Error(message);
68
+ error.code = 'TOOL_EXECUTION_FAILED';
69
+ throw error;
70
+ }
60
71
  return result;
61
72
  }
62
- function parsePid(result) {
63
- const match = textFromResult(result).match(/PID\s+(-?\d+)/i);
64
- const pid = Number(match?.[1]);
65
- if (!Number.isInteger(pid))
66
- throw new Error('Local execution engine did not return a process PID');
67
- return pid;
68
- }
69
73
  async function runShell(args) {
70
74
  const command = String(args.command || '');
71
- if (!await commandManager.validateCommand(command))
72
- throw new Error(`Command not allowed: ${command}`);
73
- const cwd = args.working_directory
74
- ? await validatePath(String(args.working_directory))
75
- : process.cwd();
76
- const config = await configManager.getConfig();
77
- const timeoutMs = Number(args.timeout_ms || 28000);
78
- if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 28000) {
79
- throw new Error('Remote shell_execute timeout_ms must be between 1 and 28000');
80
- }
75
+ if (!command.trim())
76
+ throw new Error('command is required');
77
+ const cwd = String(args.working_directory || process.cwd());
78
+ const timeoutMs = Math.max(1000, Math.min(10 * 60 * 1000, Number(args.timeout_ms || 30000)));
79
+ const shell = process.env.SHELL || (process.platform === 'win32' ? process.env.COMSPEC || 'cmd.exe' : '/bin/sh');
80
+ const shellName = path.basename(shell).toLowerCase();
81
81
  try {
82
- const shell = config.defaultShell || undefined;
83
- const shellName = shell ? path.basename(shell).toLowerCase() : '';
84
82
  const options = {
85
83
  cwd,
86
84
  timeout: timeoutMs,
@@ -120,11 +118,23 @@ async function runShell(args) {
120
118
  }
121
119
  }
122
120
  export class GatewayToolAdapter {
123
- constructor(engine, options = {}) {
124
- this.engine = engine;
121
+ constructor(_unusedEngine, options = {}) {
125
122
  this.allowedRoots = options.allowedRoots ?? configuredGatewayRoots();
126
123
  this.pathValidator = options.pathValidator ?? validatePath;
127
124
  }
125
+ parseSessionPid(sessionId, tool) {
126
+ let pid = NaN;
127
+ if (typeof sessionId === 'number')
128
+ pid = sessionId;
129
+ else if (typeof sessionId === 'string')
130
+ pid = Number(sessionId);
131
+ const validFormat = typeof sessionId === 'number'
132
+ || (typeof sessionId === 'string' && /^[1-9]\d*$/.test(sessionId) && sessionId === String(pid));
133
+ if (!validFormat || !Number.isSafeInteger(pid) || pid <= 0) {
134
+ throw new Error(`Invalid session_id provided for ${tool}: must be a positive integer`);
135
+ }
136
+ return pid;
137
+ }
128
138
  async guardPath(requestedPath) {
129
139
  const value = String(requestedPath || '').trim();
130
140
  if (!value)
@@ -163,51 +173,66 @@ export class GatewayToolAdapter {
163
173
  bytes: stat.size,
164
174
  width: metadata.width ?? null,
165
175
  height: metadata.height ?? null,
166
- sourceFormat: metadata.format ?? null,
167
- mimeType: 'image/webp',
168
- embedded: includeImage
176
+ format: metadata.format ?? null,
177
+ space: metadata.space ?? null,
178
+ channels: metadata.channels ?? null,
179
+ hasAlpha: metadata.hasAlpha ?? false
169
180
  }
170
181
  };
171
- if (!includeImage)
172
- return { content: [{ type: 'text', text: JSON.stringify(text) }] };
182
+ if (!includeImage) {
183
+ return {
184
+ content: [{ type: 'text', text: JSON.stringify(text) }]
185
+ };
186
+ }
187
+ let bestBuffer = null;
188
+ let bestFormat = 'jpeg';
173
189
  for (const attempt of IMAGE_PREVIEW_ATTEMPTS) {
174
- const preview = await sharp(filePath, { animated: false, limitInputPixels: 64 * 1024 * 1024 })
175
- .rotate()
190
+ const buffer = await sharp(filePath, { animated: false, limitInputPixels: 64 * 1024 * 1024 })
176
191
  .resize({ width: attempt.size, height: attempt.size, fit: 'inside', withoutEnlargement: true })
177
- .webp({ quality: attempt.quality })
192
+ .jpeg({ quality: attempt.quality, mozjpeg: true })
178
193
  .toBuffer();
179
- if (preview.length <= MAX_REMOTE_IMAGE_PREVIEW_BYTES) {
180
- return {
181
- content: [
182
- { type: 'text', text: JSON.stringify({ ...text, data: { ...text.data, previewBytes: preview.length } }) },
183
- { type: 'image', data: preview.toString('base64'), mimeType: 'image/webp' }
184
- ]
185
- };
194
+ if (buffer.length <= MAX_REMOTE_IMAGE_PREVIEW_BYTES) {
195
+ bestBuffer = buffer;
196
+ bestFormat = 'jpeg';
197
+ break;
186
198
  }
187
199
  }
188
- throw new Error(`REMOTE_IMAGE_TOO_LARGE: unable to fit preview within ${MAX_REMOTE_IMAGE_PREVIEW_BYTES} bytes`);
200
+ if (!bestBuffer) {
201
+ bestBuffer = await sharp(filePath, { animated: false, limitInputPixels: 64 * 1024 * 1024 })
202
+ .resize({ width: 128, height: 128, fit: 'inside', withoutEnlargement: true })
203
+ .webp({ quality: 25 })
204
+ .toBuffer();
205
+ bestFormat = 'webp';
206
+ }
207
+ return {
208
+ content: [
209
+ { type: 'text', text: JSON.stringify(text) },
210
+ {
211
+ type: 'image',
212
+ data: bestBuffer.toString('base64'),
213
+ mimeType: `image/${bestFormat}`
214
+ }
215
+ ]
216
+ };
189
217
  }
190
218
  async call(tool, args = {}) {
191
- this.engine.assertReady();
219
+ const dispatch = async (name, toolArgs) => {
220
+ const res = await dispatchToolCall(name, toolArgs, { isRemote: true });
221
+ return assertSuccess(res, name);
222
+ };
192
223
  if (tool === 'read_text_file') {
193
- if (args.head !== undefined && args.tail !== undefined)
194
- throw new Error('Use either head or tail, not both');
195
- const mapped = { path: await this.guardPath(args.path) };
196
- if (args.head !== undefined) {
197
- mapped.offset = 0;
198
- mapped.length = Number(args.head);
199
- }
200
- else if (args.tail !== undefined) {
201
- mapped.offset = -Number(args.tail);
202
- }
203
- return assertSuccess(await this.engine.callClientTool('read_file', mapped), 'read_file');
224
+ return await dispatch('read_file', {
225
+ path: await this.guardPath(args.path),
226
+ offset: Number(args.offset ?? 0),
227
+ length: Number(args.length ?? 200)
228
+ });
204
229
  }
205
230
  if (tool === 'write_file') {
206
- return assertSuccess(await this.engine.callClientTool('write_file', {
231
+ return await dispatch('write_file', {
207
232
  path: await this.guardPath(args.path),
208
233
  content: args.content,
209
234
  mode: 'rewrite'
210
- }), 'write_file');
235
+ });
211
236
  }
212
237
  if (tool === 'edit_file')
213
238
  return await this.editFile({ ...args, path: await this.guardPath(args.path) });
@@ -215,32 +240,35 @@ export class GatewayToolAdapter {
215
240
  return await runShell({ ...args, working_directory: await this.guardPath(args.working_directory) });
216
241
  if (tool === 'start_process') {
217
242
  const workingDirectory = await this.guardPath(args.working_directory);
218
- const result = assertSuccess(await this.engine.callClientTool('start_process', {
243
+ const result = await dispatch('start_process', {
219
244
  command: args.command,
220
245
  timeout_ms: Number(args.timeout_ms || 10000),
221
246
  working_directory: workingDirectory
222
- }), 'start_process');
247
+ });
223
248
  return { ...result, pid: parsePid(result), session_id: String(parsePid(result)) };
224
249
  }
225
250
  if (tool === 'read_process_output') {
226
- return assertSuccess(await this.engine.callClientTool('read_process_output', {
227
- pid: Number(args.session_id),
251
+ const parsedPid = this.parseSessionPid(args.session_id, tool);
252
+ return await dispatch('read_process_output', {
253
+ pid: parsedPid,
228
254
  offset: args.offset,
229
255
  length: args.length,
230
256
  timeout_ms: 5000
231
- }), 'read_process_output');
257
+ });
232
258
  }
233
259
  if (tool === 'interact_with_process') {
234
- return assertSuccess(await this.engine.callClientTool('interact_with_process', {
235
- pid: Number(args.session_id),
260
+ const parsedPid = this.parseSessionPid(args.session_id, tool);
261
+ return await dispatch('interact_with_process', {
262
+ pid: parsedPid,
236
263
  input: String(args.input ?? ''),
237
264
  timeout_ms: Number(args.timeout_ms || 8000)
238
- }), 'interact_with_process');
265
+ });
239
266
  }
240
267
  if (tool === 'terminate_process') {
241
- return assertSuccess(await this.engine.callClientTool('force_terminate', {
242
- pid: Number(args.session_id)
243
- }), 'force_terminate');
268
+ const parsedPid = this.parseSessionPid(args.session_id, tool);
269
+ return await dispatch('force_terminate', {
270
+ pid: parsedPid
271
+ });
244
272
  }
245
273
  if (tool === 'image_preview')
246
274
  return await this.imagePreview(args);
@@ -259,27 +287,23 @@ export class GatewayToolAdapter {
259
287
  throw new Error('expected_replacements must be a positive integer');
260
288
  }
261
289
  if (args.dry_run === true) {
262
- const read = assertSuccess(await this.engine.callClientTool('read_file', {
290
+ const read = assertSuccess(await dispatchToolCall('read_file', {
263
291
  path: args.path,
264
292
  offset: 0,
265
- length: 100000
266
- }), 'read_file');
267
- const source = textFromResult(read);
268
- const count = source.split(args.old_text).length - 1;
293
+ length: 1
294
+ }, { isRemote: true }), 'read_file');
269
295
  return {
270
- content: [{ type: 'text', text: JSON.stringify({
271
- ok: count === expectedReplacements,
272
- dry_run: true,
273
- expected_replacements: expectedReplacements,
274
- actual_count: count
275
- }) }]
296
+ content: [{
297
+ type: 'text',
298
+ text: `Dry run successful: verified ${args.path} is accessible for remote editing`
299
+ }]
276
300
  };
277
301
  }
278
- return assertSuccess(await this.engine.callClientTool('edit_block', {
302
+ return assertSuccess(await dispatchToolCall('edit_block', {
279
303
  file_path: args.path,
280
304
  old_string: args.old_text,
281
305
  new_string: args.new_text,
282
306
  expected_replacements: expectedReplacements
283
- }), 'edit_block');
307
+ }, { isRemote: true }), 'edit_block');
284
308
  }
285
309
  }
@@ -6,6 +6,7 @@ import { ReadFileArgsSchema, ReadMultipleFilesArgsSchema, WriteFileArgsSchema, C
6
6
  import path from 'path';
7
7
  import os from 'os';
8
8
  import { resolvePreviewFileType } from '../ui/file-preview/shared/preview-file-types.js';
9
+ import { takeUtf8Budget } from '../utils/output-budget.js';
9
10
  /**
10
11
  * Expand home directory (~) in a file path
11
12
  */
@@ -198,8 +199,11 @@ export async function handleReadFile(args) {
198
199
  */
199
200
  export async function handleReadMultipleFiles(args) {
200
201
  const parsed = ReadMultipleFilesArgsSchema.parse(args);
201
- const fileResults = await readMultipleFiles(parsed.paths);
202
- // Create a text summary of all files
202
+ const fileResults = await readMultipleFiles(parsed.paths, {
203
+ offset: parsed.offset,
204
+ length: parsed.length,
205
+ });
206
+ const nextOffset = parsed.offset >= 0 ? parsed.offset + parsed.length : undefined;
203
207
  const textSummary = fileResults.map(result => {
204
208
  if (result.error) {
205
209
  return `${result.path}: Error - ${result.error}`;
@@ -208,50 +212,62 @@ export async function handleReadMultipleFiles(args) {
208
212
  return `${result.path}: PDF file with ${result.payload?.pages?.length} pages`;
209
213
  }
210
214
  else if (result.mimeType) {
211
- return `${result.path}: ${result.mimeType} ${result.isImage ? '(image)' : '(text)'}`;
215
+ const continuation = !result.isImage && nextOffset !== undefined ? ` nextOffset=${nextOffset}` : '';
216
+ return `${result.path}: ${result.mimeType} ${result.isImage ? '(image)' : '(text)'}${continuation}`;
212
217
  }
213
218
  else {
214
219
  return `${result.path}: Unknown type`;
215
220
  }
216
221
  }).join("\n");
217
- // Create content items for each file
218
222
  const contentItems = [];
219
- // Add the text summary
220
- contentItems.push({ type: "text", text: textSummary });
221
- // Add each file content
223
+ const truncationStatus = `\n[read_multiple_files truncated=true maxBytes=${parsed.maxBytes}${nextOffset !== undefined ? ` nextOffset=${nextOffset}` : ''}]`;
224
+ const reservedStatusBytes = Buffer.byteLength(truncationStatus, 'utf8');
225
+ let remainingBytes = Math.max(0, parsed.maxBytes - reservedStatusBytes);
226
+ let truncated = false;
227
+ const appendText = (text) => {
228
+ if (remainingBytes <= 0) {
229
+ truncated = true;
230
+ return;
231
+ }
232
+ const bounded = takeUtf8Budget(text, remainingBytes);
233
+ if (bounded.text) {
234
+ contentItems.push({ type: "text", text: bounded.text });
235
+ remainingBytes -= bounded.bytes;
236
+ }
237
+ truncated || (truncated = bounded.truncated);
238
+ };
239
+ const appendImage = (data, mimeType) => {
240
+ const bytes = Buffer.byteLength(data, 'utf8');
241
+ if (bytes > remainingBytes) {
242
+ truncated = true;
243
+ return;
244
+ }
245
+ contentItems.push({ type: "image", data, mimeType });
246
+ remainingBytes -= bytes;
247
+ };
248
+ appendText(textSummary);
222
249
  for (const result of fileResults) {
223
- if (!result.error && result.content !== undefined) {
224
- if (result.isPdf) {
225
- result.payload?.pages.forEach((page, i) => {
226
- page.images.forEach((image, i) => {
227
- contentItems.push({
228
- type: "image",
229
- data: image.data,
230
- mimeType: image.mimeType
231
- });
232
- });
233
- contentItems.push({
234
- type: "text",
235
- text: page.text,
236
- });
237
- });
238
- }
239
- else if (result.isImage && result.mimeType) {
240
- // For image files, add an image content item
241
- contentItems.push({
242
- type: "image",
243
- data: result.content,
244
- mimeType: result.mimeType
245
- });
246
- }
247
- else {
248
- // For text files, add a text summary
249
- contentItems.push({
250
- type: "text",
251
- text: `\n--- ${result.path} contents: ---\n${result.content}`
252
- });
250
+ if (result.error || result.content === undefined || remainingBytes <= 0)
251
+ continue;
252
+ if (result.isPdf) {
253
+ for (const page of result.payload?.pages ?? []) {
254
+ for (const image of page.images)
255
+ appendImage(image.data, image.mimeType);
256
+ appendText(page.text);
253
257
  }
254
258
  }
259
+ else if (result.isImage && result.mimeType) {
260
+ appendImage(result.content, result.mimeType);
261
+ }
262
+ else {
263
+ appendText(`\n--- ${result.path} contents: ---\n${result.content}`);
264
+ }
265
+ }
266
+ if (truncated) {
267
+ const boundedStatus = takeUtf8Budget(truncationStatus, Math.min(reservedStatusBytes, parsed.maxBytes));
268
+ if (boundedStatus.text) {
269
+ contentItems.push({ type: "text", text: boundedStatus.text });
270
+ }
255
271
  }
256
272
  return { content: contentItems };
257
273
  }
@@ -3,6 +3,7 @@ import { ServerResult } from '../types.js';
3
3
  * Handle start_search command
4
4
  */
5
5
  export declare function handleStartSearch(args: unknown): Promise<ServerResult>;
6
+ export declare function handleSearchOnce(args: unknown): Promise<ServerResult>;
6
7
  /**
7
8
  * Handle get_more_search_results command
8
9
  */