@ebowwa/mcp-nm 2.1.0 → 2.2.0

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.
@@ -0,0 +1,535 @@
1
+ /**
2
+ * @ebowwa/mcp-nm - Binary patching handlers
3
+ *
4
+ * MCP tool handlers for binary patching operations
5
+ */
6
+
7
+ import { execAsync, isMacOS } from "../utils/exec";
8
+ import { hexToBytes } from "../utils/xxd";
9
+ import { runXxd } from "../utils/xxd";
10
+ import { convertNumber } from "../utils/convert";
11
+ import type { McpResponse } from "../types";
12
+
13
+ /**
14
+ * Patch bytes at offset
15
+ */
16
+ export async function handlePatchBytes(args: {
17
+ filePath: string;
18
+ offset: number;
19
+ hexData: string;
20
+ createBackup?: boolean;
21
+ }): Promise<McpResponse> {
22
+ const createBackup = args.createBackup !== false;
23
+
24
+ try {
25
+ const results: string[] = [];
26
+ results.push(`Patch Bytes: ${args.filePath}`);
27
+ results.push(` Offset: 0x${args.offset.toString(16)} (${args.offset})`);
28
+ results.push(` Data: ${args.hexData}`);
29
+ results.push("");
30
+
31
+ // Create backup if requested
32
+ if (createBackup) {
33
+ const backupPath = `${args.filePath}.bak`;
34
+ await execAsync(`cp "${args.filePath}" "${backupPath}"`);
35
+ results.push(`Backup created: ${backupPath}`);
36
+ }
37
+
38
+ // Read original bytes for verification
39
+ const { stdout: originalHex } = await execAsync(
40
+ `xxd -s ${args.offset} -l ${args.hexData.replace(/\s/g, "").length / 2} -p "${args.filePath}" | tr -d '\\n'`
41
+ );
42
+ results.push(`Original bytes: ${originalHex.trim()}`);
43
+
44
+ // Apply the patch using printf and dd
45
+ const hexBytes = args.hexData.replace(/\s+/g, "");
46
+ const byteCount = hexBytes.length / 2;
47
+ const patchCmd = `printf '${hexBytes}' | dd of="${args.filePath}" bs=1 seek=${args.offset} count=${byteCount} conv=notrunc 2>/dev/null`;
48
+ await execAsync(patchCmd);
49
+ results.push(`Patched ${byteCount} bytes`);
50
+
51
+ // Verify the patch
52
+ const { stdout: newHex } = await execAsync(
53
+ `xxd -s ${args.offset} -l ${byteCount} -p "${args.filePath}" | tr -d '\\n'`
54
+ );
55
+ results.push(`New bytes: ${newHex.trim()}`);
56
+
57
+ if (newHex.trim().toLowerCase() === hexBytes.toLowerCase()) {
58
+ results.push("Verification: SUCCESS");
59
+ } else {
60
+ results.push("Verification: WARNING - Bytes don't match expected value");
61
+ }
62
+
63
+ return { content: [{ type: "text", text: results.join("\n") }] };
64
+ } catch (error) {
65
+ return {
66
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
67
+ isError: true,
68
+ };
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Insert NOP sled at offset
74
+ */
75
+ export async function handleNopSled(args: {
76
+ filePath: string;
77
+ offset: number;
78
+ count: number;
79
+ createBackup?: boolean;
80
+ }): Promise<McpResponse> {
81
+ const createBackup = args.createBackup !== false;
82
+
83
+ // NOP = 0x90 on x86/x64
84
+ const nopHex = "90".repeat(args.count);
85
+
86
+ return handlePatchBytes({
87
+ filePath: args.filePath,
88
+ offset: args.offset,
89
+ hexData: nopHex,
90
+ createBackup,
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Hex editor mode - read or modify bytes
96
+ */
97
+ export async function handleHexEditor(args: {
98
+ filePath: string;
99
+ offset: number;
100
+ length: number;
101
+ newHex?: string;
102
+ }): Promise<McpResponse> {
103
+ try {
104
+ // If newHex provided, modify; otherwise just read
105
+ if (args.newHex) {
106
+ return handlePatchBytes({
107
+ filePath: args.filePath,
108
+ offset: args.offset,
109
+ hexData: args.newHex,
110
+ createBackup: true,
111
+ });
112
+ }
113
+
114
+ // Read mode
115
+ const result = await runXxd(args.filePath, {
116
+ seek: args.offset,
117
+ length: args.length,
118
+ cols: 16,
119
+ groupSize: 1,
120
+ });
121
+
122
+ // Also get plain hex for easy copying
123
+ const { stdout: plainHex } = await execAsync(
124
+ `xxd -s ${args.offset} -l ${args.length} -p "${args.filePath}" | tr -d '\\n'`
125
+ );
126
+
127
+ const summary = [
128
+ `Hex Editor: ${args.filePath}`,
129
+ `Offset: 0x${args.offset.toString(16)} (${args.offset})`,
130
+ `Length: ${args.length} bytes`,
131
+ "",
132
+ "Hex dump:",
133
+ result.output,
134
+ "",
135
+ "Plain hex (for copying):",
136
+ plainHex.trim(),
137
+ ].join("\n");
138
+
139
+ return { content: [{ type: "text", text: summary }] };
140
+ } catch (error) {
141
+ return {
142
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
143
+ isError: true,
144
+ };
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Number conversion utility
150
+ */
151
+ export async function handleConvertNumber(args: {
152
+ value: string;
153
+ fromFormat?: "hex" | "decimal" | "binary" | "octal" | "auto";
154
+ toFormat?: "all" | "hex" | "decimal" | "binary" | "octal" | "ascii";
155
+ byteSize?: 8 | 16 | 32 | 64;
156
+ signed?: boolean;
157
+ }): Promise<McpResponse> {
158
+ const result = convertNumber(args.value, {
159
+ fromFormat: args.fromFormat ?? "auto",
160
+ toFormat: args.toFormat ?? "all",
161
+ byteSize: args.byteSize,
162
+ signed: args.signed,
163
+ });
164
+
165
+ const summary: string[] = [
166
+ `Number Conversion: ${args.value}`,
167
+ `Input format: ${args.fromFormat ?? "auto-detect"}`,
168
+ "",
169
+ "Results:",
170
+ ` Decimal: ${result.decimal}`,
171
+ ` Hex: ${result.hex}`,
172
+ ` Binary: ${result.binary}`,
173
+ ` Octal: ${result.octal}`,
174
+ ` ASCII: ${result.ascii}`,
175
+ ];
176
+
177
+ if (result.signed !== undefined) {
178
+ summary.push(` Signed: ${result.signed}`);
179
+ }
180
+
181
+ if (result.byteBreakdown && result.byteBreakdown.length > 0) {
182
+ summary.push("");
183
+ summary.push("Byte breakdown:");
184
+ for (const byte of result.byteBreakdown) {
185
+ summary.push(` Byte ${byte.index}: 0x${byte.hex} (${byte.decimal})`);
186
+ }
187
+ }
188
+
189
+ return { content: [{ type: "text", text: summary.join("\n") }] };
190
+ }
191
+
192
+ /**
193
+ * Patch manifest for persistent patches
194
+ */
195
+ interface PatchEntry {
196
+ id: string;
197
+ offset: number;
198
+ originalBytes: string;
199
+ patchedBytes: string;
200
+ description: string;
201
+ applied: boolean;
202
+ timestamp: string;
203
+ }
204
+
205
+ const patchManifest: Map<string, PatchEntry[]> = new Map();
206
+
207
+ function getPatches(filePath: string): PatchEntry[] {
208
+ if (!patchManifest.has(filePath)) {
209
+ patchManifest.set(filePath, []);
210
+ }
211
+ return patchManifest.get(filePath)!;
212
+ }
213
+
214
+ /**
215
+ * Register a patch for persistence
216
+ */
217
+ export async function handlePatchRegister(args: {
218
+ filePath: string;
219
+ patchId: string;
220
+ offset: number;
221
+ patchedBytes: string;
222
+ description: string;
223
+ captureOriginal?: boolean;
224
+ }): Promise<McpResponse> {
225
+ const patches = getPatches(args.filePath);
226
+
227
+ // Check for duplicate ID
228
+ if (patches.some((p) => p.id === args.patchId)) {
229
+ return {
230
+ content: [{ type: "text", text: `Error: Patch ID "${args.patchId}" already exists for this file.` }],
231
+ isError: true,
232
+ };
233
+ }
234
+
235
+ let originalBytes = "";
236
+ if (args.captureOriginal !== false) {
237
+ try {
238
+ const { stdout } = await execAsync(
239
+ `xxd -s ${args.offset} -l ${args.patchedBytes.replace(/\s/g, "").length / 2} -p "${args.filePath}" | tr -d '\\n'`
240
+ );
241
+ originalBytes = stdout.trim();
242
+ } catch {
243
+ originalBytes = "unknown";
244
+ }
245
+ }
246
+
247
+ const patch: PatchEntry = {
248
+ id: args.patchId,
249
+ offset: args.offset,
250
+ originalBytes,
251
+ patchedBytes: args.patchedBytes.replace(/\s/g, ""),
252
+ description: args.description,
253
+ applied: false,
254
+ timestamp: new Date().toISOString(),
255
+ };
256
+
257
+ patches.push(patch);
258
+
259
+ return {
260
+ content: [{
261
+ type: "text",
262
+ text: [
263
+ `Patch Registered: ${args.patchId}`,
264
+ ` File: ${args.filePath}`,
265
+ ` Offset: 0x${args.offset.toString(16)}`,
266
+ ` Original: ${originalBytes || "(not captured)"}`,
267
+ ` Patched: ${patch.patchedBytes}`,
268
+ ` Description: ${args.description}`,
269
+ "",
270
+ "Patch has been registered but not yet applied.",
271
+ "Use bin_patch_apply to apply all registered patches.",
272
+ ].join("\n"),
273
+ }],
274
+ };
275
+ }
276
+
277
+ /**
278
+ * List registered patches
279
+ */
280
+ export async function handlePatchList(args: { filePath?: string }): Promise<McpResponse> {
281
+ if (args.filePath) {
282
+ const patches = getPatches(args.filePath);
283
+ if (patches.length === 0) {
284
+ return { content: [{ type: "text", text: `No patches registered for ${args.filePath}` }] };
285
+ }
286
+
287
+ return {
288
+ content: [{
289
+ type: "text",
290
+ text: [
291
+ `Patches for ${args.filePath}:`,
292
+ "",
293
+ ...patches.map(
294
+ (p) =>
295
+ ` [${p.applied ? "X" : " "}] ${p.id}: offset 0x${p.offset.toString(16)} - ${p.description}`
296
+ ),
297
+ ].join("\n"),
298
+ }],
299
+ };
300
+ }
301
+
302
+ // List all patches
303
+ const allPatches = Array.from(patchManifest.entries()).flatMap(([file, patches]) =>
304
+ patches.map((p) => ({ file, ...p }))
305
+ );
306
+
307
+ if (allPatches.length === 0) {
308
+ return { content: [{ type: "text", text: "No patches registered." }] };
309
+ }
310
+
311
+ return {
312
+ content: [{
313
+ type: "text",
314
+ text: [
315
+ "All Registered Patches:",
316
+ "",
317
+ ...allPatches.map(
318
+ (p) =>
319
+ ` [${p.applied ? "X" : " "}] ${p.file}#${p.id}: offset 0x${p.offset.toString(16)} - ${p.description}`
320
+ ),
321
+ ].join("\n"),
322
+ }],
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Apply all registered patches
328
+ */
329
+ export async function handlePatchApply(args: {
330
+ filePath: string;
331
+ resign?: boolean;
332
+ removeQuarantine?: boolean;
333
+ }): Promise<McpResponse> {
334
+ const patches = getPatches(args.filePath);
335
+
336
+ if (patches.length === 0) {
337
+ return {
338
+ content: [{ type: "text", text: `No patches registered for ${args.filePath}` }],
339
+ };
340
+ }
341
+
342
+ const results: string[] = [];
343
+ results.push(`Applying Patches: ${args.filePath}`);
344
+ results.push("");
345
+
346
+ let appliedCount = 0;
347
+
348
+ for (const patch of patches) {
349
+ try {
350
+ const byteCount = patch.patchedBytes.length / 2;
351
+ const patchCmd = `printf '${patch.patchedBytes}' | dd of="${args.filePath}" bs=1 seek=${patch.offset} count=${byteCount} conv=notrunc 2>/dev/null`;
352
+ await execAsync(patchCmd);
353
+ patch.applied = true;
354
+ appliedCount++;
355
+ results.push(`[OK] ${patch.id} at 0x${patch.offset.toString(16)}`);
356
+ } catch (e) {
357
+ results.push(`[FAIL] ${patch.id}: ${e instanceof Error ? e.message : String(e)}`);
358
+ }
359
+ }
360
+
361
+ results.push("");
362
+ results.push(`Applied ${appliedCount}/${patches.length} patches.`);
363
+
364
+ // macOS-specific post-processing
365
+ if (isMacOS() && appliedCount > 0) {
366
+ if (args.resign !== false) {
367
+ results.push("");
368
+ results.push("Re-signing binary...");
369
+ try {
370
+ await execAsync(`codesign --remove-signature "${args.filePath}" 2>&1 || true`);
371
+ await execAsync(`codesign --force --sign - "${args.filePath}"`);
372
+ results.push(" Ad-hoc signature applied.");
373
+ } catch (e) {
374
+ results.push(` Warning: ${e instanceof Error ? e.message : String(e)}`);
375
+ }
376
+ }
377
+
378
+ if (args.removeQuarantine !== false) {
379
+ results.push("");
380
+ results.push("Removing quarantine...");
381
+ try {
382
+ await execAsync(`xattr -d com.apple.quarantine "${args.filePath}" 2>&1 || true`);
383
+ results.push(" Quarantine removed.");
384
+ } catch {
385
+ results.push(" No quarantine attribute present.");
386
+ }
387
+ }
388
+ }
389
+
390
+ return { content: [{ type: "text", text: results.join("\n") }] };
391
+ }
392
+
393
+ /**
394
+ * Restore original bytes for patches
395
+ */
396
+ export async function handlePatchRestore(args: {
397
+ filePath: string;
398
+ patchId?: string;
399
+ resign?: boolean;
400
+ }): Promise<McpResponse> {
401
+ const patches = getPatches(args.filePath);
402
+
403
+ if (patches.length === 0) {
404
+ return {
405
+ content: [{ type: "text", text: `No patches registered for ${args.filePath}` }],
406
+ };
407
+ }
408
+
409
+ const results: string[] = [];
410
+ results.push(`Restoring Patches: ${args.filePath}`);
411
+ results.push("");
412
+
413
+ const toRestore = args.patchId
414
+ ? patches.filter((p) => p.id === args.patchId)
415
+ : patches.filter((p) => p.applied);
416
+
417
+ if (toRestore.length === 0) {
418
+ return {
419
+ content: [{ type: "text", text: args.patchId ? `Patch "${args.patchId}" not found.` : "No applied patches to restore." }],
420
+ };
421
+ }
422
+
423
+ let restoredCount = 0;
424
+
425
+ for (const patch of toRestore) {
426
+ if (patch.originalBytes === "unknown" || !patch.originalBytes) {
427
+ results.push(`[SKIP] ${patch.id}: No original bytes captured`);
428
+ continue;
429
+ }
430
+
431
+ try {
432
+ const byteCount = patch.originalBytes.length / 2;
433
+ const restoreCmd = `printf '${patch.originalBytes}' | dd of="${args.filePath}" bs=1 seek=${patch.offset} count=${byteCount} conv=notrunc 2>/dev/null`;
434
+ await execAsync(restoreCmd);
435
+ patch.applied = false;
436
+ restoredCount++;
437
+ results.push(`[OK] ${patch.id} restored at 0x${patch.offset.toString(16)}`);
438
+ } catch (e) {
439
+ results.push(`[FAIL] ${patch.id}: ${e instanceof Error ? e.message : String(e)}`);
440
+ }
441
+ }
442
+
443
+ results.push("");
444
+ results.push(`Restored ${restoredCount}/${toRestore.length} patches.`);
445
+
446
+ // Re-sign on macOS
447
+ if (isMacOS() && restoredCount > 0 && args.resign !== false) {
448
+ results.push("");
449
+ results.push("Re-signing binary...");
450
+ try {
451
+ await execAsync(`codesign --remove-signature "${args.filePath}" 2>&1 || true`);
452
+ await execAsync(`codesign --force --sign - "${args.filePath}"`);
453
+ results.push(" Ad-hoc signature applied.");
454
+ } catch (e) {
455
+ results.push(` Warning: ${e instanceof Error ? e.message : String(e)}`);
456
+ }
457
+ }
458
+
459
+ return { content: [{ type: "text", text: results.join("\n") }] };
460
+ }
461
+
462
+ /**
463
+ * Verify registered patches
464
+ */
465
+ export async function handlePatchVerify(args: { filePath: string }): Promise<McpResponse> {
466
+ const patches = getPatches(args.filePath);
467
+
468
+ if (patches.length === 0) {
469
+ return {
470
+ content: [{ type: "text", text: `No patches registered for ${args.filePath}` }],
471
+ };
472
+ }
473
+
474
+ const results: string[] = [];
475
+ results.push(`Verifying Patches: ${args.filePath}`);
476
+ results.push("");
477
+
478
+ for (const patch of patches) {
479
+ try {
480
+ const byteCount = patch.patchedBytes.length / 2;
481
+ const { stdout: currentBytes } = await execAsync(
482
+ `xxd -s ${patch.offset} -l ${byteCount} -p "${args.filePath}" | tr -d '\\n'`
483
+ );
484
+ const current = currentBytes.trim().toLowerCase();
485
+ const expected = patch.patchedBytes.toLowerCase();
486
+
487
+ if (current === expected) {
488
+ results.push(`[APPLIED] ${patch.id}: Bytes match`);
489
+ } else if (patch.originalBytes && current === patch.originalBytes.toLowerCase()) {
490
+ results.push(`[NOT APPLIED] ${patch.id}: Original bytes detected`);
491
+ } else {
492
+ results.push(`[UNKNOWN] ${patch.id}: Unexpected bytes (${current})`);
493
+ results.push(` Expected: ${expected}`);
494
+ }
495
+ } catch (e) {
496
+ results.push(`[ERROR] ${patch.id}: ${e instanceof Error ? e.message : String(e)}`);
497
+ }
498
+ }
499
+
500
+ return { content: [{ type: "text", text: results.join("\n") }] };
501
+ }
502
+
503
+ /**
504
+ * Remove a patch from registry
505
+ */
506
+ export async function handlePatchRemove(args: {
507
+ filePath: string;
508
+ patchId: string;
509
+ }): Promise<McpResponse> {
510
+ const patches = getPatches(args.filePath);
511
+ const index = patches.findIndex((p) => p.id === args.patchId);
512
+
513
+ if (index === -1) {
514
+ return {
515
+ content: [{ type: "text", text: `Patch "${args.patchId}" not found.` }],
516
+ isError: true,
517
+ };
518
+ }
519
+
520
+ const removed = patches.splice(index, 1)[0];
521
+
522
+ return {
523
+ content: [{
524
+ type: "text",
525
+ text: [
526
+ `Patch Removed: ${args.patchId}`,
527
+ ` File: ${args.filePath}`,
528
+ ` Offset: 0x${removed.offset.toString(16)}`,
529
+ ` Description: ${removed.description}`,
530
+ "",
531
+ "Note: This only removes from registry. Binary is unchanged.",
532
+ ].join("\n"),
533
+ }],
534
+ };
535
+ }