@hcu-lab.me/mcp-device 1.0.9 → 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 +1 -1
- package/dist/device/device.d.ts +1 -0
- package/dist/device/device.js +13 -4
- package/dist/device/gateway-tool-adapter.d.ts +2 -3
- package/dist/device/gateway-tool-adapter.js +121 -97
- package/dist/handlers/filesystem-handlers.js +53 -37
- package/dist/handlers/search-handlers.d.ts +1 -0
- package/dist/handlers/search-handlers.js +51 -1
- package/dist/mcp-device.d.ts +1 -1
- package/dist/mcp-device.js +1 -1
- package/dist/search-manager.d.ts +6 -0
- package/dist/search-manager.js +123 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +2 -2
- package/dist/terminal-manager.d.ts +9 -0
- package/dist/terminal-manager.js +127 -42
- package/dist/tool-dispatcher.d.ts +11 -0
- package/dist/tool-dispatcher.js +224 -0
- package/dist/tools/edit.js +72 -1
- package/dist/tools/filesystem.d.ts +1 -1
- package/dist/tools/filesystem.js +2 -2
- package/dist/tools/improved-process-tools.js +87 -108
- package/dist/tools/schemas.d.ts +94 -2
- package/dist/tools/schemas.js +20 -6
- package/dist/types.d.ts +7 -0
- package/dist/utils/output-budget.d.ts +6 -0
- package/dist/utils/output-budget.js +15 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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.
|
|
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
|
package/dist/device/device.d.ts
CHANGED
package/dist/device/device.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
119
|
-
|
|
120
|
-
|
|
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({
|
|
@@ -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(
|
|
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 =
|
|
34
|
-
if (!raw)
|
|
32
|
+
const raw = process.env.MCP_GATEWAY_ALLOWED_ROOTS || '';
|
|
33
|
+
if (!raw.trim())
|
|
35
34
|
return [];
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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,
|
|
58
|
-
if (result
|
|
59
|
-
|
|
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 (!
|
|
72
|
-
throw new Error(
|
|
73
|
-
const cwd = args.working_directory
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
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(
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 {
|
|
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
|
|
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
|
-
.
|
|
192
|
+
.jpeg({ quality: attempt.quality, mozjpeg: true })
|
|
178
193
|
.toBuffer();
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
231
|
+
return await dispatch('write_file', {
|
|
207
232
|
path: await this.guardPath(args.path),
|
|
208
233
|
content: args.content,
|
|
209
234
|
mode: 'rewrite'
|
|
210
|
-
})
|
|
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 =
|
|
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
|
-
})
|
|
247
|
+
});
|
|
223
248
|
return { ...result, pid: parsePid(result), session_id: String(parsePid(result)) };
|
|
224
249
|
}
|
|
225
250
|
if (tool === 'read_process_output') {
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
})
|
|
257
|
+
});
|
|
232
258
|
}
|
|
233
259
|
if (tool === 'interact_with_process') {
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
})
|
|
265
|
+
});
|
|
239
266
|
}
|
|
240
267
|
if (tool === 'terminate_process') {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
290
|
+
const read = assertSuccess(await dispatchToolCall('read_file', {
|
|
263
291
|
path: args.path,
|
|
264
292
|
offset: 0,
|
|
265
|
-
length:
|
|
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: [{
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
*/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { searchManager } from '../search-manager.js';
|
|
2
|
-
import { StartSearchArgsSchema, GetMoreSearchResultsArgsSchema, StopSearchArgsSchema } from '../tools/schemas.js';
|
|
2
|
+
import { StartSearchArgsSchema, SearchOnceArgsSchema, GetMoreSearchResultsArgsSchema, StopSearchArgsSchema } from '../tools/schemas.js';
|
|
3
3
|
import { capture } from '../utils/capture.js';
|
|
4
|
+
import { takeUtf8Budget } from '../utils/output-budget.js';
|
|
4
5
|
/**
|
|
5
6
|
* Handle start_search command
|
|
6
7
|
*/
|
|
@@ -66,6 +67,55 @@ export async function handleStartSearch(args) {
|
|
|
66
67
|
};
|
|
67
68
|
}
|
|
68
69
|
}
|
|
70
|
+
export async function handleSearchOnce(args) {
|
|
71
|
+
const parsed = SearchOnceArgsSchema.safeParse(args);
|
|
72
|
+
if (!parsed.success) {
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: "text", text: `Invalid arguments for search_once: ${parsed.error}` }],
|
|
75
|
+
isError: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const maxResults = parsed.data.maxResults ?? 100;
|
|
80
|
+
const result = await searchManager.searchOnce({
|
|
81
|
+
rootPath: parsed.data.path,
|
|
82
|
+
pattern: parsed.data.pattern,
|
|
83
|
+
searchType: parsed.data.searchType,
|
|
84
|
+
filePattern: parsed.data.filePattern,
|
|
85
|
+
ignoreCase: parsed.data.ignoreCase,
|
|
86
|
+
maxResults,
|
|
87
|
+
includeHidden: parsed.data.includeHidden,
|
|
88
|
+
contextLines: parsed.data.contextLines,
|
|
89
|
+
timeout: parsed.data.timeout_ms,
|
|
90
|
+
earlyTermination: parsed.data.earlyTermination,
|
|
91
|
+
literalSearch: parsed.data.literalSearch,
|
|
92
|
+
}, maxResults, parsed.data.maxBytes);
|
|
93
|
+
let body = '';
|
|
94
|
+
for (const item of result.results) {
|
|
95
|
+
body += item.type === 'content'
|
|
96
|
+
? `${item.file}:${item.line ?? 0} - ${item.match ?? ''}\n`
|
|
97
|
+
: `${item.file}\n`;
|
|
98
|
+
}
|
|
99
|
+
if (!body)
|
|
100
|
+
body = 'No matches found.\n';
|
|
101
|
+
const status = `[search_once truncated=${result.truncated} timedOut=${result.timedOut} results=${result.results.length} runtimeMs=${result.runtime}]`;
|
|
102
|
+
const bodyBudget = Math.max(0, parsed.data.maxBytes - Buffer.byteLength(status, 'utf8') - 1);
|
|
103
|
+
const bounded = takeUtf8Budget(body, bodyBudget);
|
|
104
|
+
const truncated = result.truncated || bounded.truncated;
|
|
105
|
+
const finalStatus = `[search_once truncated=${truncated} timedOut=${result.timedOut} results=${result.results.length} runtimeMs=${result.runtime}]`;
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: "text", text: `${bounded.text}${finalStatus}` }],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
112
|
+
capture('search_once_error', { error: errorMessage });
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text", text: `Error running one-shot search: ${errorMessage}` }],
|
|
115
|
+
isError: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
69
119
|
/**
|
|
70
120
|
* Handle get_more_search_results command
|
|
71
121
|
*/
|
package/dist/mcp-device.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import './bootstrap.js';
|
package/dist/mcp-device.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import './bootstrap.js';
|
|
2
3
|
// MCP Device publishes only the authenticated device runtime surface.
|
|
3
4
|
if (process.argv[2] !== 'remote')
|
|
4
5
|
process.argv.splice(2, 0, 'remote');
|
|
5
6
|
const { runRemote } = await import('./npm-scripts/remote.js');
|
|
6
7
|
await runRemote();
|
|
7
|
-
export {};
|
package/dist/search-manager.d.ts
CHANGED
|
@@ -51,6 +51,12 @@ export interface SearchSessionOptions {
|
|
|
51
51
|
totalResults: number;
|
|
52
52
|
runtime: number;
|
|
53
53
|
}>;
|
|
54
|
+
searchOnce(options: SearchSessionOptions, maxResults?: number, maxBytes?: number): Promise<{
|
|
55
|
+
results: SearchResult[];
|
|
56
|
+
runtime: number;
|
|
57
|
+
truncated: boolean;
|
|
58
|
+
timedOut: boolean;
|
|
59
|
+
}>;
|
|
54
60
|
/**
|
|
55
61
|
* Read search results with offset-based pagination (like read_file)
|
|
56
62
|
* Supports both range reading and tail behavior
|