bach-filecommander-mcp 1.3.0 → 1.4.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.
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * See LICENSE file for details.
10
10
  *
11
11
  * @author Lukas (BACH)
12
- * @version 1.3.0
12
+ * @version 1.4.0
13
13
  * @license MIT
14
14
  */
15
15
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -18,6 +18,8 @@ import { z } from "zod";
18
18
  import * as fs from "fs/promises";
19
19
  import * as fsSync from "fs";
20
20
  import * as path from "path";
21
+ import * as crypto from "crypto";
22
+ import * as os from "os";
21
23
  import { exec, spawn } from "child_process";
22
24
  import { promisify } from "util";
23
25
  const execAsync = promisify(exec);
@@ -26,7 +28,7 @@ const execAsync = promisify(exec);
26
28
  // ============================================================================
27
29
  const server = new McpServer({
28
30
  name: "bach-filecommander-mcp",
29
- version: "1.3.0"
31
+ version: "1.4.0"
30
32
  });
31
33
  const processSessions = new Map();
32
34
  let sessionCounter = 0;
@@ -2023,6 +2025,1038 @@ Args:
2023
2025
  }
2024
2026
  });
2025
2027
  // ============================================================================
2028
+ // Tool: Fix JSON
2029
+ // ============================================================================
2030
+ server.registerTool("fc_fix_json", {
2031
+ title: "JSON reparieren",
2032
+ description: `Repariert häufige JSON-Fehler automatisch.
2033
+
2034
+ Args:
2035
+ - path (string): Pfad zur JSON-Datei
2036
+ - dry_run (boolean, optional): Nur Probleme anzeigen, nicht reparieren
2037
+ - create_backup (boolean, optional): Backup erstellen vor Reparatur
2038
+
2039
+ Repariert: BOM, Trailing Commas, Single Quotes, Kommentare, NUL-Bytes`,
2040
+ inputSchema: {
2041
+ path: z.string().min(1).describe("Pfad zur JSON-Datei"),
2042
+ dry_run: z.boolean().default(false).describe("Nur Probleme anzeigen"),
2043
+ create_backup: z.boolean().default(true).describe("Backup erstellen")
2044
+ },
2045
+ annotations: {
2046
+ readOnlyHint: false,
2047
+ destructiveHint: false,
2048
+ idempotentHint: true,
2049
+ openWorldHint: false
2050
+ }
2051
+ }, async (params) => {
2052
+ try {
2053
+ const filePath = normalizePath(params.path);
2054
+ if (!await pathExists(filePath)) {
2055
+ return { isError: true, content: [{ type: "text", text: `❌ Datei nicht gefunden: ${filePath}` }] };
2056
+ }
2057
+ const rawContent = await fs.readFile(filePath, "utf-8");
2058
+ const fixes = [];
2059
+ let content = rawContent;
2060
+ // Remove BOM
2061
+ if (content.charCodeAt(0) === 0xFEFF) {
2062
+ content = content.slice(1);
2063
+ fixes.push("UTF-8 BOM entfernt");
2064
+ }
2065
+ // Remove NUL bytes
2066
+ if (content.includes('\0')) {
2067
+ content = content.replace(/\0/g, '');
2068
+ fixes.push("NUL-Bytes entfernt");
2069
+ }
2070
+ // Remove single-line comments
2071
+ const c1 = content;
2072
+ content = content.replace(/^(\s*)\/\/.*$/gm, '');
2073
+ if (content !== c1)
2074
+ fixes.push("Einzeilige Kommentare entfernt");
2075
+ // Remove multi-line comments
2076
+ const c2 = content;
2077
+ content = content.replace(/\/\*[\s\S]*?\*\//g, '');
2078
+ if (content !== c2)
2079
+ fixes.push("Mehrzeilige Kommentare entfernt");
2080
+ // Fix trailing commas before } or ]
2081
+ const c3 = content;
2082
+ content = content.replace(/,(\s*[}\]])/g, '$1');
2083
+ if (content !== c3)
2084
+ fixes.push("Trailing Commas entfernt");
2085
+ // Fix single quotes to double quotes for keys and simple values
2086
+ const c4 = content;
2087
+ content = content.replace(/(\s*)'([^'\\]*(?:\\.[^'\\]*)*)'\s*:/g, '$1"$2":');
2088
+ content = content.replace(/:\s*'([^'\\]*(?:\\.[^'\\]*)*)'/g, ': "$1"');
2089
+ if (content !== c4)
2090
+ fixes.push("Single Quotes → Double Quotes");
2091
+ // Try to parse
2092
+ let isValid = false;
2093
+ let parseError = '';
2094
+ try {
2095
+ JSON.parse(content);
2096
+ isValid = true;
2097
+ }
2098
+ catch (e) {
2099
+ parseError = e instanceof Error ? e.message : String(e);
2100
+ }
2101
+ if (fixes.length === 0 && isValid) {
2102
+ return { content: [{ type: "text", text: `✅ ${path.basename(filePath)} ist bereits gültiges JSON.` }] };
2103
+ }
2104
+ if (params.dry_run) {
2105
+ return {
2106
+ content: [{ type: "text", text: [
2107
+ `🔍 **JSON-Analyse: ${path.basename(filePath)}**`, '',
2108
+ fixes.length > 0 ? `**Gefundene Probleme:**` : `Keine automatisch reparierbaren Probleme.`,
2109
+ ...fixes.map(f => ` - ${f}`), '',
2110
+ isValid ? `✅ Nach Reparatur: Gültiges JSON` : `⚠️ Nach Reparatur noch ungültig: ${parseError}`
2111
+ ].join('\n') }]
2112
+ };
2113
+ }
2114
+ if (params.create_backup && fixes.length > 0) {
2115
+ await fs.writeFile(filePath + '.bak', rawContent, "utf-8");
2116
+ }
2117
+ if (isValid) {
2118
+ content = JSON.stringify(JSON.parse(content), null, 2);
2119
+ }
2120
+ await fs.writeFile(filePath, content, "utf-8");
2121
+ return {
2122
+ content: [{ type: "text", text: [
2123
+ `✅ **JSON repariert: ${path.basename(filePath)}**`, '',
2124
+ ...fixes.map(f => ` - ${f}`), '',
2125
+ isValid ? `✅ Gültiges JSON` : `⚠️ Noch ungültig: ${parseError}`,
2126
+ params.create_backup ? `📋 Backup: ${filePath}.bak` : ''
2127
+ ].join('\n') }]
2128
+ };
2129
+ }
2130
+ catch (error) {
2131
+ return { isError: true, content: [{ type: "text", text: `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` }] };
2132
+ }
2133
+ });
2134
+ // ============================================================================
2135
+ // Tool: Validate JSON
2136
+ // ============================================================================
2137
+ server.registerTool("fc_validate_json", {
2138
+ title: "JSON validieren",
2139
+ description: `Validiert eine JSON-Datei und zeigt detaillierte Fehlerinformationen.
2140
+
2141
+ Args:
2142
+ - path (string): Pfad zur JSON-Datei
2143
+
2144
+ Returns:
2145
+ - Validierungsstatus mit Zeile/Spalte bei Fehlern`,
2146
+ inputSchema: {
2147
+ path: z.string().min(1).describe("Pfad zur JSON-Datei")
2148
+ },
2149
+ annotations: {
2150
+ readOnlyHint: true,
2151
+ destructiveHint: false,
2152
+ idempotentHint: true,
2153
+ openWorldHint: false
2154
+ }
2155
+ }, async (params) => {
2156
+ try {
2157
+ const filePath = normalizePath(params.path);
2158
+ if (!await pathExists(filePath)) {
2159
+ return { isError: true, content: [{ type: "text", text: `❌ Datei nicht gefunden: ${filePath}` }] };
2160
+ }
2161
+ const content = await fs.readFile(filePath, "utf-8");
2162
+ const stats = await fs.stat(filePath);
2163
+ try {
2164
+ const parsed = JSON.parse(content);
2165
+ const keyCount = typeof parsed === 'object' && parsed !== null ? Object.keys(parsed).length : 0;
2166
+ const type = Array.isArray(parsed) ? `Array (${parsed.length} Elemente)` : typeof parsed === 'object' && parsed !== null ? `Objekt (${keyCount} Schlüssel)` : typeof parsed;
2167
+ return {
2168
+ content: [{ type: "text", text: [
2169
+ `✅ **Gültiges JSON: ${path.basename(filePath)}**`, '',
2170
+ `| Eigenschaft | Wert |`, `|---|---|`,
2171
+ `| Typ | ${type} |`,
2172
+ `| Größe | ${formatFileSize(stats.size)} |`,
2173
+ `| BOM | ${content.charCodeAt(0) === 0xFEFF ? '⚠️ Ja' : 'Nein'} |`,
2174
+ `| Encoding | UTF-8 |`
2175
+ ].join('\n') }]
2176
+ };
2177
+ }
2178
+ catch (e) {
2179
+ const errorMsg = e instanceof Error ? e.message : String(e);
2180
+ // Extract position from error message
2181
+ const posMatch = errorMsg.match(/position\s+(\d+)/i);
2182
+ let lineInfo = '';
2183
+ if (posMatch) {
2184
+ const pos = parseInt(posMatch[1]);
2185
+ const before = content.substring(0, pos);
2186
+ const line = before.split('\n').length;
2187
+ const col = pos - before.lastIndexOf('\n');
2188
+ const lines = content.split('\n');
2189
+ const contextLines = lines.slice(Math.max(0, line - 3), line + 2);
2190
+ lineInfo = `\n**Fehlerposition:** Zeile ${line}, Spalte ${col}\n\n\`\`\`\n${contextLines.map((l, i) => `${Math.max(1, line - 2) + i}: ${l}`).join('\n')}\n\`\`\``;
2191
+ }
2192
+ return {
2193
+ content: [{ type: "text", text: `❌ **Ungültiges JSON: ${path.basename(filePath)}**\n\n**Fehler:** ${errorMsg}${lineInfo}\n\n💡 Nutze \`fc_fix_json\` für automatische Reparatur.` }]
2194
+ };
2195
+ }
2196
+ }
2197
+ catch (error) {
2198
+ return { isError: true, content: [{ type: "text", text: `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` }] };
2199
+ }
2200
+ });
2201
+ // ============================================================================
2202
+ // Tool: Cleanup File
2203
+ // ============================================================================
2204
+ server.registerTool("fc_cleanup_file", {
2205
+ title: "Datei bereinigen",
2206
+ description: `Bereinigt eine oder mehrere Dateien von häufigen Problemen.
2207
+
2208
+ Args:
2209
+ - path (string): Pfad zu Datei oder Verzeichnis
2210
+ - recursive (boolean, optional): Bei Verzeichnis rekursiv
2211
+ - extensions (string, optional): Dateierweiterungen filtern (z.B. ".txt,.json,.py")
2212
+ - remove_bom (boolean): UTF-8 BOM entfernen
2213
+ - remove_trailing_whitespace (boolean): Trailing Whitespace entfernen
2214
+ - normalize_line_endings (string, optional): "lf" | "crlf" | null
2215
+ - remove_nul_bytes (boolean): NUL-Bytes entfernen
2216
+ - dry_run (boolean): Nur anzeigen
2217
+
2218
+ Bereinigt: BOM, NUL-Bytes, Trailing Whitespace, Line Endings`,
2219
+ inputSchema: {
2220
+ path: z.string().min(1).describe("Pfad zu Datei/Verzeichnis"),
2221
+ recursive: z.boolean().default(false).describe("Rekursiv"),
2222
+ extensions: z.string().optional().describe("Erweiterungen filtern (.txt,.json)"),
2223
+ remove_bom: z.boolean().default(true).describe("BOM entfernen"),
2224
+ remove_trailing_whitespace: z.boolean().default(true).describe("Trailing Whitespace"),
2225
+ normalize_line_endings: z.enum(["lf", "crlf"]).optional().describe("Line Endings"),
2226
+ remove_nul_bytes: z.boolean().default(true).describe("NUL-Bytes entfernen"),
2227
+ dry_run: z.boolean().default(false).describe("Nur anzeigen")
2228
+ },
2229
+ annotations: {
2230
+ readOnlyHint: false,
2231
+ destructiveHint: false,
2232
+ idempotentHint: true,
2233
+ openWorldHint: false
2234
+ }
2235
+ }, async (params) => {
2236
+ try {
2237
+ const targetPath = normalizePath(params.path);
2238
+ if (!await pathExists(targetPath)) {
2239
+ return { isError: true, content: [{ type: "text", text: `❌ Pfad nicht gefunden: ${targetPath}` }] };
2240
+ }
2241
+ const stats = await fs.stat(targetPath);
2242
+ const extFilter = params.extensions ? params.extensions.split(',').map(e => e.trim().toLowerCase()) : null;
2243
+ // Collect files
2244
+ const files = [];
2245
+ if (stats.isDirectory()) {
2246
+ async function collectFiles(dir) {
2247
+ const entries = await fs.readdir(dir, { withFileTypes: true });
2248
+ for (const entry of entries) {
2249
+ const full = path.join(dir, entry.name);
2250
+ if (entry.isDirectory() && params.recursive) {
2251
+ if (!['node_modules', '.git', '$RECYCLE.BIN'].includes(entry.name)) {
2252
+ await collectFiles(full);
2253
+ }
2254
+ }
2255
+ else if (entry.isFile()) {
2256
+ if (!extFilter || extFilter.includes(path.extname(entry.name).toLowerCase())) {
2257
+ files.push(full);
2258
+ }
2259
+ }
2260
+ }
2261
+ }
2262
+ await collectFiles(targetPath);
2263
+ }
2264
+ else {
2265
+ files.push(targetPath);
2266
+ }
2267
+ const results = [];
2268
+ let totalFixed = 0;
2269
+ for (const filePath of files) {
2270
+ try {
2271
+ const raw = await fs.readFile(filePath, "utf-8");
2272
+ let content = raw;
2273
+ const fixes = [];
2274
+ if (params.remove_bom && content.charCodeAt(0) === 0xFEFF) {
2275
+ content = content.slice(1);
2276
+ fixes.push("BOM");
2277
+ }
2278
+ if (params.remove_nul_bytes && content.includes('\0')) {
2279
+ content = content.replace(/\0/g, '');
2280
+ fixes.push("NUL");
2281
+ }
2282
+ if (params.remove_trailing_whitespace) {
2283
+ const c = content;
2284
+ content = content.replace(/[ \t]+$/gm, '');
2285
+ if (content !== c)
2286
+ fixes.push("Whitespace");
2287
+ }
2288
+ if (params.normalize_line_endings) {
2289
+ const c = content;
2290
+ content = content.replace(/\r\n/g, '\n');
2291
+ if (params.normalize_line_endings === 'crlf') {
2292
+ content = content.replace(/\n/g, '\r\n');
2293
+ }
2294
+ if (content !== c)
2295
+ fixes.push(params.normalize_line_endings.toUpperCase());
2296
+ }
2297
+ if (fixes.length > 0) {
2298
+ if (!params.dry_run) {
2299
+ await fs.writeFile(filePath, content, "utf-8");
2300
+ }
2301
+ results.push(` ✅ ${path.relative(targetPath, filePath) || path.basename(filePath)} [${fixes.join(', ')}]`);
2302
+ totalFixed++;
2303
+ }
2304
+ }
2305
+ catch {
2306
+ // Skip binary/unreadable files
2307
+ }
2308
+ }
2309
+ if (totalFixed === 0) {
2310
+ return { content: [{ type: "text", text: `✅ Keine Bereinigung nötig. ${files.length} Dateien geprüft.` }] };
2311
+ }
2312
+ return {
2313
+ content: [{ type: "text", text: [
2314
+ `${params.dry_run ? '🔍 **Vorschau**' : '✅ **Bereinigt**'}: ${totalFixed}/${files.length} Dateien`, '',
2315
+ ...results
2316
+ ].join('\n') }]
2317
+ };
2318
+ }
2319
+ catch (error) {
2320
+ return { isError: true, content: [{ type: "text", text: `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` }] };
2321
+ }
2322
+ });
2323
+ // ============================================================================
2324
+ // Tool: Fix Encoding
2325
+ // ============================================================================
2326
+ server.registerTool("fc_fix_encoding", {
2327
+ title: "Encoding reparieren",
2328
+ description: `Erkennt und repariert Encoding-Fehler (Mojibake, doppeltes UTF-8).
2329
+
2330
+ Args:
2331
+ - path (string): Pfad zur Datei
2332
+ - dry_run (boolean): Nur Probleme anzeigen
2333
+ - create_backup (boolean): Backup erstellen
2334
+
2335
+ Repariert häufige Mojibake-Muster wie:
2336
+ - ä → ä, ö → ö, ü → ü
2337
+ - Ä → Ä, Ö → Ö, Ü → Ü
2338
+ - ß → ß, € → €`,
2339
+ inputSchema: {
2340
+ path: z.string().min(1).describe("Pfad zur Datei"),
2341
+ dry_run: z.boolean().default(false).describe("Nur anzeigen"),
2342
+ create_backup: z.boolean().default(true).describe("Backup erstellen")
2343
+ },
2344
+ annotations: {
2345
+ readOnlyHint: false,
2346
+ destructiveHint: false,
2347
+ idempotentHint: true,
2348
+ openWorldHint: false
2349
+ }
2350
+ }, async (params) => {
2351
+ try {
2352
+ const filePath = normalizePath(params.path);
2353
+ if (!await pathExists(filePath)) {
2354
+ return { isError: true, content: [{ type: "text", text: `❌ Datei nicht gefunden: ${filePath}` }] };
2355
+ }
2356
+ const rawContent = await fs.readFile(filePath, "utf-8");
2357
+ // Common Mojibake patterns (UTF-8 decoded as Latin-1 then re-encoded as UTF-8)
2358
+ const mojibakeMap = [
2359
+ [/ä/g, 'ä', 'ä'], [/ö/g, 'ö', 'ö'], [/ü/g, 'ü', 'ü'],
2360
+ [/Ä/g, 'Ä', 'Ä'], [/Ö/g, 'Ö', 'Ö'], [/Ü/g, 'Ü', 'Ü'],
2361
+ [/ß/g, 'ß', 'ß'], [/€/g, '€', '€'],
2362
+ [/é/g, 'é', 'é'], [/è/g, 'è', 'è'],
2363
+ [/à /g, 'à', 'à'], [/á/g, 'á', 'á'],
2364
+ [/î/g, 'î', 'î'], [/ï/g, 'ï', 'ï'],
2365
+ [/ô/g, 'ô', 'ô'], [/ù/g, 'ù', 'ù'],
2366
+ [/ç/g, 'ç', 'ç'], [/ñ/g, 'ñ', 'ñ'],
2367
+ [/\u00e2\u0080\u0093/g, '\u2013', 'en-dash'], [/\u00e2\u0080\u0094/g, '\u2014', 'em-dash'],
2368
+ [/\u00e2\u0080\u009c/g, '\u201C', 'left-dquote'], [/\u00e2\u0080\u009d/g, '\u201D', 'right-dquote'],
2369
+ [/\u00e2\u0080\u0098/g, '\u2018', 'left-squote'], [/\u00e2\u0080\u0099/g, '\u2019', 'right-squote'],
2370
+ [/\u00c2\u00a0/g, ' ', 'NBSP'], [/\u00c2\u00a9/g, '\u00A9', '\u00A9'],
2371
+ [/\u00c2\u00ae/g, '\u00AE', '\u00AE'], [/\u00c2\u00b0/g, '\u00B0', '\u00B0'],
2372
+ ];
2373
+ let content = rawContent;
2374
+ const fixes = [];
2375
+ for (const [pattern, replacement, label] of mojibakeMap) {
2376
+ const before = content;
2377
+ content = content.replace(pattern, replacement);
2378
+ if (content !== before) {
2379
+ const count = (before.match(pattern) || []).length;
2380
+ fixes.push(`${label} (${count}x)`);
2381
+ }
2382
+ }
2383
+ if (fixes.length === 0) {
2384
+ return { content: [{ type: "text", text: `✅ Keine Encoding-Fehler in ${path.basename(filePath)} gefunden.` }] };
2385
+ }
2386
+ if (params.dry_run) {
2387
+ return {
2388
+ content: [{ type: "text", text: [
2389
+ `🔍 **Encoding-Analyse: ${path.basename(filePath)}**`, '',
2390
+ `**Gefundene Mojibake-Muster:**`,
2391
+ ...fixes.map(f => ` - ${f}`)
2392
+ ].join('\n') }]
2393
+ };
2394
+ }
2395
+ if (params.create_backup) {
2396
+ await fs.writeFile(filePath + '.bak', rawContent, "utf-8");
2397
+ }
2398
+ await fs.writeFile(filePath, content, "utf-8");
2399
+ return {
2400
+ content: [{ type: "text", text: [
2401
+ `✅ **Encoding repariert: ${path.basename(filePath)}**`, '',
2402
+ ...fixes.map(f => ` - ${f}`),
2403
+ params.create_backup ? `\n📋 Backup: ${filePath}.bak` : ''
2404
+ ].join('\n') }]
2405
+ };
2406
+ }
2407
+ catch (error) {
2408
+ return { isError: true, content: [{ type: "text", text: `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` }] };
2409
+ }
2410
+ });
2411
+ // ============================================================================
2412
+ // Tool: Folder Diff
2413
+ // ============================================================================
2414
+ server.registerTool("fc_folder_diff", {
2415
+ title: "Verzeichnis-Änderungen erkennen",
2416
+ description: `Vergleicht den aktuellen Zustand eines Verzeichnisses mit einem gespeicherten Snapshot.
2417
+
2418
+ Args:
2419
+ - path (string): Pfad zum Verzeichnis
2420
+ - save_snapshot (boolean): Aktuellen Zustand als neuen Snapshot speichern
2421
+ - extensions (string, optional): Dateierweiterungen filtern
2422
+
2423
+ Erkennt: Neue Dateien, geänderte Dateien, gelöschte Dateien
2424
+ Snapshots werden in %TEMP%/.fc_snapshots/ gespeichert.`,
2425
+ inputSchema: {
2426
+ path: z.string().min(1).describe("Pfad zum Verzeichnis"),
2427
+ save_snapshot: z.boolean().default(true).describe("Snapshot speichern"),
2428
+ extensions: z.string().optional().describe("Erweiterungen filtern")
2429
+ },
2430
+ annotations: {
2431
+ readOnlyHint: true,
2432
+ destructiveHint: false,
2433
+ idempotentHint: true,
2434
+ openWorldHint: false
2435
+ }
2436
+ }, async (params) => {
2437
+ try {
2438
+ const dirPath = normalizePath(params.path);
2439
+ if (!await pathExists(dirPath)) {
2440
+ return { isError: true, content: [{ type: "text", text: `❌ Verzeichnis nicht gefunden: ${dirPath}` }] };
2441
+ }
2442
+ const extFilter = params.extensions ? params.extensions.split(',').map(e => e.trim().toLowerCase()) : null;
2443
+ const snapshotDir = path.join(os.tmpdir(), '.fc_snapshots');
2444
+ const snapshotId = crypto.createHash('md5').update(dirPath).digest('hex');
2445
+ const snapshotFile = path.join(snapshotDir, `${snapshotId}.json`);
2446
+ const currentState = {};
2447
+ async function scanDir(dir) {
2448
+ try {
2449
+ const entries = await fs.readdir(dir, { withFileTypes: true });
2450
+ for (const entry of entries) {
2451
+ const full = path.join(dir, entry.name);
2452
+ if (entry.isDirectory()) {
2453
+ if (!['node_modules', '.git', '$RECYCLE.BIN'].includes(entry.name)) {
2454
+ await scanDir(full);
2455
+ }
2456
+ }
2457
+ else if (entry.isFile()) {
2458
+ if (!extFilter || extFilter.includes(path.extname(entry.name).toLowerCase())) {
2459
+ const stats = await fs.stat(full);
2460
+ const rel = path.relative(dirPath, full);
2461
+ currentState[rel] = { size: stats.size, mtime: stats.mtimeMs };
2462
+ }
2463
+ }
2464
+ }
2465
+ }
2466
+ catch { /* skip inaccessible dirs */ }
2467
+ }
2468
+ await scanDir(dirPath);
2469
+ // Load previous snapshot
2470
+ let previousState = {};
2471
+ let hasSnapshot = false;
2472
+ try {
2473
+ const data = await fs.readFile(snapshotFile, "utf-8");
2474
+ previousState = JSON.parse(data);
2475
+ hasSnapshot = true;
2476
+ }
2477
+ catch { /* no previous snapshot */ }
2478
+ // Compare
2479
+ const newFiles = [];
2480
+ const modifiedFiles = [];
2481
+ const deletedFiles = [];
2482
+ for (const [rel, entry] of Object.entries(currentState)) {
2483
+ if (!previousState[rel]) {
2484
+ newFiles.push(rel);
2485
+ }
2486
+ else if (entry.size !== previousState[rel].size || Math.abs(entry.mtime - previousState[rel].mtime) > 1000) {
2487
+ modifiedFiles.push(rel);
2488
+ }
2489
+ }
2490
+ for (const rel of Object.keys(previousState)) {
2491
+ if (!currentState[rel]) {
2492
+ deletedFiles.push(rel);
2493
+ }
2494
+ }
2495
+ // Save snapshot
2496
+ if (params.save_snapshot) {
2497
+ await fs.mkdir(snapshotDir, { recursive: true });
2498
+ await fs.writeFile(snapshotFile, JSON.stringify(currentState), "utf-8");
2499
+ }
2500
+ const totalFiles = Object.keys(currentState).length;
2501
+ const totalChanges = newFiles.length + modifiedFiles.length + deletedFiles.length;
2502
+ if (!hasSnapshot) {
2503
+ return {
2504
+ content: [{ type: "text", text: [
2505
+ `📸 **Erster Snapshot erstellt: ${path.basename(dirPath)}**`, '',
2506
+ `| | |`, `|---|---|`,
2507
+ `| Dateien | ${totalFiles} |`,
2508
+ `| Snapshot | ${snapshotFile} |`, '',
2509
+ `Beim nächsten Aufruf werden Änderungen erkannt.`
2510
+ ].join('\n') }]
2511
+ };
2512
+ }
2513
+ if (totalChanges === 0) {
2514
+ return { content: [{ type: "text", text: `✅ Keine Änderungen in ${path.basename(dirPath)}. ${totalFiles} Dateien geprüft.` }] };
2515
+ }
2516
+ const output = [
2517
+ `📊 **Verzeichnis-Diff: ${path.basename(dirPath)}**`, '',
2518
+ `| Kategorie | Anzahl |`, `|---|---|`,
2519
+ `| Neue Dateien | ${newFiles.length} |`,
2520
+ `| Geändert | ${modifiedFiles.length} |`,
2521
+ `| Gelöscht | ${deletedFiles.length} |`,
2522
+ `| Unverändert | ${totalFiles - newFiles.length - modifiedFiles.length} |`
2523
+ ];
2524
+ if (newFiles.length > 0) {
2525
+ output.push('', '**Neue Dateien:**', ...newFiles.slice(0, 50).map(f => ` 🟢 ${f}`));
2526
+ if (newFiles.length > 50)
2527
+ output.push(` ... und ${newFiles.length - 50} weitere`);
2528
+ }
2529
+ if (modifiedFiles.length > 0) {
2530
+ output.push('', '**Geänderte Dateien:**', ...modifiedFiles.slice(0, 50).map(f => ` 🟡 ${f}`));
2531
+ if (modifiedFiles.length > 50)
2532
+ output.push(` ... und ${modifiedFiles.length - 50} weitere`);
2533
+ }
2534
+ if (deletedFiles.length > 0) {
2535
+ output.push('', '**Gelöschte Dateien:**', ...deletedFiles.slice(0, 50).map(f => ` 🔴 ${f}`));
2536
+ if (deletedFiles.length > 50)
2537
+ output.push(` ... und ${deletedFiles.length - 50} weitere`);
2538
+ }
2539
+ return { content: [{ type: "text", text: output.join('\n') }] };
2540
+ }
2541
+ catch (error) {
2542
+ return { isError: true, content: [{ type: "text", text: `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` }] };
2543
+ }
2544
+ });
2545
+ // ============================================================================
2546
+ // Tool: Batch Rename
2547
+ // ============================================================================
2548
+ server.registerTool("fc_batch_rename", {
2549
+ title: "Batch-Umbenennung",
2550
+ description: `Benennt Dateien nach Muster um: Prefix/Suffix entfernen, ersetzen, oder Pattern.
2551
+
2552
+ Args:
2553
+ - directory (string): Verzeichnis mit den Dateien
2554
+ - mode (string): "remove_prefix" | "remove_suffix" | "replace" | "auto_detect"
2555
+ - pattern (string, optional): Zu entfernender/ersetzender Text
2556
+ - replacement (string, optional): Ersetzungstext (für replace-Modus)
2557
+ - extensions (string, optional): Nur bestimmte Erweiterungen
2558
+ - dry_run (boolean): Nur Vorschau
2559
+
2560
+ Beispiele:
2561
+ - Prefix entfernen: mode="remove_prefix", pattern="backup_"
2562
+ - Auto-Detect: mode="auto_detect" erkennt gemeinsame Prefixe`,
2563
+ inputSchema: {
2564
+ directory: z.string().min(1).describe("Verzeichnis"),
2565
+ mode: z.enum(["remove_prefix", "remove_suffix", "replace", "auto_detect"]).describe("Modus"),
2566
+ pattern: z.string().optional().describe("Zu entfernender/ersetzender Text"),
2567
+ replacement: z.string().default("").describe("Ersetzungstext"),
2568
+ extensions: z.string().optional().describe("Erweiterungen filtern"),
2569
+ dry_run: z.boolean().default(true).describe("Nur Vorschau")
2570
+ },
2571
+ annotations: {
2572
+ readOnlyHint: false,
2573
+ destructiveHint: true,
2574
+ idempotentHint: false,
2575
+ openWorldHint: false
2576
+ }
2577
+ }, async (params) => {
2578
+ try {
2579
+ const dirPath = normalizePath(params.directory);
2580
+ if (!await pathExists(dirPath)) {
2581
+ return { isError: true, content: [{ type: "text", text: `❌ Verzeichnis nicht gefunden: ${dirPath}` }] };
2582
+ }
2583
+ const extFilter = params.extensions ? params.extensions.split(',').map(e => e.trim().toLowerCase()) : null;
2584
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
2585
+ const files = entries.filter(e => e.isFile() && (!extFilter || extFilter.includes(path.extname(e.name).toLowerCase())));
2586
+ if (files.length === 0) {
2587
+ return { content: [{ type: "text", text: `🔍 Keine passenden Dateien in ${dirPath}` }] };
2588
+ }
2589
+ const renames = [];
2590
+ if (params.mode === 'auto_detect') {
2591
+ // Find common prefix
2592
+ const names = files.map(f => f.name);
2593
+ let commonPrefix = names[0] || '';
2594
+ for (let i = 1; i < names.length; i++) {
2595
+ while (!names[i].startsWith(commonPrefix) && commonPrefix.length > 0) {
2596
+ commonPrefix = commonPrefix.slice(0, -1);
2597
+ }
2598
+ }
2599
+ // Find common suffix (before extension)
2600
+ const stems = files.map(f => path.parse(f.name).name);
2601
+ let commonSuffix = stems[0] || '';
2602
+ for (let i = 1; i < stems.length; i++) {
2603
+ while (!stems[i].endsWith(commonSuffix) && commonSuffix.length > 0) {
2604
+ commonSuffix = commonSuffix.slice(1);
2605
+ }
2606
+ }
2607
+ const detections = [];
2608
+ if (commonPrefix.length >= 3)
2609
+ detections.push(`Prefix: "${commonPrefix}"`);
2610
+ if (commonSuffix.length >= 3)
2611
+ detections.push(`Suffix: "${commonSuffix}"`);
2612
+ if (detections.length === 0) {
2613
+ return { content: [{ type: "text", text: `🔍 Kein gemeinsames Muster erkannt bei ${files.length} Dateien.` }] };
2614
+ }
2615
+ // Use prefix if found
2616
+ if (commonPrefix.length >= 3) {
2617
+ for (const f of files) {
2618
+ const newName = f.name.slice(commonPrefix.length);
2619
+ if (newName.length > 0) {
2620
+ renames.push({ old: f.name, new: newName });
2621
+ }
2622
+ }
2623
+ }
2624
+ return {
2625
+ content: [{ type: "text", text: [
2626
+ `🔍 **Auto-Detect: ${files.length} Dateien**`, '',
2627
+ `Erkannte Muster: ${detections.join(', ')}`, '',
2628
+ renames.length > 0 ? `**Vorgeschlagene Umbenennung (Prefix "${commonPrefix}" entfernen):**` : '',
2629
+ ...renames.slice(0, 30).map(r => ` ${r.old} → ${r.new}`),
2630
+ renames.length > 30 ? ` ... und ${renames.length - 30} weitere` : '', '',
2631
+ `💡 Nutze \`mode="remove_prefix", pattern="${commonPrefix}", dry_run=false\` zum Ausführen.`
2632
+ ].join('\n') }]
2633
+ };
2634
+ }
2635
+ if (!params.pattern) {
2636
+ return { isError: true, content: [{ type: "text", text: `❌ 'pattern' erforderlich für Modus "${params.mode}".` }] };
2637
+ }
2638
+ for (const f of files) {
2639
+ let newName;
2640
+ switch (params.mode) {
2641
+ case 'remove_prefix':
2642
+ newName = f.name.startsWith(params.pattern) ? f.name.slice(params.pattern.length) : f.name;
2643
+ break;
2644
+ case 'remove_suffix': {
2645
+ const parsed = path.parse(f.name);
2646
+ newName = parsed.name.endsWith(params.pattern) ? parsed.name.slice(0, -params.pattern.length) + parsed.ext : f.name;
2647
+ break;
2648
+ }
2649
+ case 'replace':
2650
+ newName = f.name.replace(new RegExp(params.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), params.replacement);
2651
+ break;
2652
+ default:
2653
+ newName = f.name;
2654
+ }
2655
+ if (newName !== f.name && newName.length > 0) {
2656
+ renames.push({ old: f.name, new: newName });
2657
+ }
2658
+ }
2659
+ if (renames.length === 0) {
2660
+ return { content: [{ type: "text", text: `🔍 Keine Dateien passen zum Muster "${params.pattern}".` }] };
2661
+ }
2662
+ if (params.dry_run) {
2663
+ return {
2664
+ content: [{ type: "text", text: [
2665
+ `🔍 **Vorschau: ${renames.length} Umbenennungen**`, '',
2666
+ ...renames.map(r => ` ${r.old} → ${r.new}`), '',
2667
+ `💡 Setze \`dry_run=false\` zum Ausführen.`
2668
+ ].join('\n') }]
2669
+ };
2670
+ }
2671
+ let successCount = 0;
2672
+ const errors = [];
2673
+ for (const r of renames) {
2674
+ try {
2675
+ await fs.rename(path.join(dirPath, r.old), path.join(dirPath, r.new));
2676
+ successCount++;
2677
+ }
2678
+ catch (e) {
2679
+ errors.push(`${r.old}: ${e instanceof Error ? e.message : String(e)}`);
2680
+ }
2681
+ }
2682
+ return {
2683
+ content: [{ type: "text", text: [
2684
+ `✅ **${successCount}/${renames.length} Dateien umbenannt**`,
2685
+ ...errors.map(e => ` ❌ ${e}`)
2686
+ ].join('\n') }]
2687
+ };
2688
+ }
2689
+ catch (error) {
2690
+ return { isError: true, content: [{ type: "text", text: `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` }] };
2691
+ }
2692
+ });
2693
+ // ============================================================================
2694
+ // Tool: Convert Format
2695
+ // ============================================================================
2696
+ server.registerTool("fc_convert_format", {
2697
+ title: "Format konvertieren",
2698
+ description: `Konvertiert Dateien zwischen verschiedenen Formaten.
2699
+
2700
+ Args:
2701
+ - input_path (string): Pfad zur Quelldatei
2702
+ - output_path (string): Pfad zur Zieldatei
2703
+ - input_format (string): "json" | "csv" | "ini"
2704
+ - output_format (string): "json" | "csv" | "ini"
2705
+ - json_indent (number, optional): Einrückung für JSON (default: 2)
2706
+
2707
+ Unterstützte Konvertierungen:
2708
+ - JSON ↔ CSV (bei Arrays von Objekten)
2709
+ - JSON ↔ INI (bei flachen Objekten/Sektionen)
2710
+ - JSON pretty-print / minify`,
2711
+ inputSchema: {
2712
+ input_path: z.string().min(1).describe("Quelldatei"),
2713
+ output_path: z.string().min(1).describe("Zieldatei"),
2714
+ input_format: z.enum(["json", "csv", "ini"]).describe("Eingabeformat"),
2715
+ output_format: z.enum(["json", "csv", "ini"]).describe("Ausgabeformat"),
2716
+ json_indent: z.number().int().min(0).max(8).default(2).describe("JSON Einrückung")
2717
+ },
2718
+ annotations: {
2719
+ readOnlyHint: false,
2720
+ destructiveHint: false,
2721
+ idempotentHint: true,
2722
+ openWorldHint: false
2723
+ }
2724
+ }, async (params) => {
2725
+ try {
2726
+ const inputPath = normalizePath(params.input_path);
2727
+ const outputPath = normalizePath(params.output_path);
2728
+ if (!await pathExists(inputPath)) {
2729
+ return { isError: true, content: [{ type: "text", text: `❌ Quelldatei nicht gefunden: ${inputPath}` }] };
2730
+ }
2731
+ const rawContent = await fs.readFile(inputPath, "utf-8");
2732
+ let data;
2733
+ // Parse input
2734
+ switch (params.input_format) {
2735
+ case 'json':
2736
+ data = JSON.parse(rawContent);
2737
+ break;
2738
+ case 'csv': {
2739
+ const lines = rawContent.trim().split('\n');
2740
+ if (lines.length < 2) {
2741
+ return { isError: true, content: [{ type: "text", text: `❌ CSV benötigt mindestens Header + 1 Datenzeile.` }] };
2742
+ }
2743
+ const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
2744
+ data = lines.slice(1).map(line => {
2745
+ const vals = line.split(',').map(v => v.trim().replace(/^"|"$/g, ''));
2746
+ const obj = {};
2747
+ headers.forEach((h, i) => { obj[h] = vals[i] || ''; });
2748
+ return obj;
2749
+ });
2750
+ break;
2751
+ }
2752
+ case 'ini': {
2753
+ const result = {};
2754
+ let currentSection = '_default';
2755
+ result[currentSection] = {};
2756
+ for (const line of rawContent.split('\n')) {
2757
+ const trimmed = line.trim();
2758
+ if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#'))
2759
+ continue;
2760
+ const sectionMatch = trimmed.match(/^\[(.+)\]$/);
2761
+ if (sectionMatch) {
2762
+ currentSection = sectionMatch[1];
2763
+ result[currentSection] = result[currentSection] || {};
2764
+ }
2765
+ else {
2766
+ const eqIdx = trimmed.indexOf('=');
2767
+ if (eqIdx > 0) {
2768
+ const key = trimmed.substring(0, eqIdx).trim();
2769
+ const val = trimmed.substring(eqIdx + 1).trim();
2770
+ result[currentSection][key] = val;
2771
+ }
2772
+ }
2773
+ }
2774
+ // Remove empty default section
2775
+ if (Object.keys(result._default).length === 0)
2776
+ delete result._default;
2777
+ data = result;
2778
+ break;
2779
+ }
2780
+ }
2781
+ // Generate output
2782
+ let output;
2783
+ switch (params.output_format) {
2784
+ case 'json':
2785
+ output = JSON.stringify(data, null, params.json_indent || undefined);
2786
+ break;
2787
+ case 'csv': {
2788
+ if (!Array.isArray(data)) {
2789
+ return { isError: true, content: [{ type: "text", text: `❌ CSV-Export erfordert ein JSON-Array von Objekten.` }] };
2790
+ }
2791
+ const headers = Object.keys(data[0] || {});
2792
+ const rows = data.map((item) => headers.map(h => {
2793
+ const val = String(item[h] ?? '');
2794
+ return val.includes(',') || val.includes('"') ? `"${val.replace(/"/g, '""')}"` : val;
2795
+ }).join(','));
2796
+ output = [headers.join(','), ...rows].join('\n');
2797
+ break;
2798
+ }
2799
+ case 'ini': {
2800
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
2801
+ return { isError: true, content: [{ type: "text", text: `❌ INI-Export erfordert ein JSON-Objekt.` }] };
2802
+ }
2803
+ const lines = [];
2804
+ for (const [section, values] of Object.entries(data)) {
2805
+ if (typeof values === 'object' && values !== null && !Array.isArray(values)) {
2806
+ lines.push(`[${section}]`);
2807
+ for (const [key, val] of Object.entries(values)) {
2808
+ lines.push(`${key} = ${val}`);
2809
+ }
2810
+ lines.push('');
2811
+ }
2812
+ else {
2813
+ lines.push(`${section} = ${values}`);
2814
+ }
2815
+ }
2816
+ output = lines.join('\n');
2817
+ break;
2818
+ }
2819
+ }
2820
+ // Ensure output directory exists
2821
+ const outDir = path.dirname(outputPath);
2822
+ if (!await pathExists(outDir)) {
2823
+ await fs.mkdir(outDir, { recursive: true });
2824
+ }
2825
+ await fs.writeFile(outputPath, output, "utf-8");
2826
+ const outStats = await fs.stat(outputPath);
2827
+ return {
2828
+ content: [{ type: "text", text: [
2829
+ `✅ **Konvertiert: ${params.input_format.toUpperCase()} → ${params.output_format.toUpperCase()}**`, '',
2830
+ `| | |`, `|---|---|`,
2831
+ `| Quelle | ${inputPath} |`,
2832
+ `| Ziel | ${outputPath} |`,
2833
+ `| Größe | ${formatFileSize(outStats.size)} |`
2834
+ ].join('\n') }]
2835
+ };
2836
+ }
2837
+ catch (error) {
2838
+ return { isError: true, content: [{ type: "text", text: `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` }] };
2839
+ }
2840
+ });
2841
+ // ============================================================================
2842
+ // Tool: Detect Duplicates
2843
+ // ============================================================================
2844
+ server.registerTool("fc_detect_duplicates", {
2845
+ title: "Duplikate erkennen",
2846
+ description: `Findet Datei-Duplikate in einem Verzeichnis anhand von SHA-256 Hashes.
2847
+
2848
+ Args:
2849
+ - directory (string): Verzeichnis zum Scannen
2850
+ - recursive (boolean): Rekursiv suchen
2851
+ - extensions (string, optional): Nur bestimmte Erweiterungen
2852
+ - min_size (number, optional): Mindestgröße in Bytes (default: 1)
2853
+ - max_size (number, optional): Maximale Größe in Bytes
2854
+
2855
+ Returns:
2856
+ - Gruppen von Duplikaten mit Pfaden und Größen`,
2857
+ inputSchema: {
2858
+ directory: z.string().min(1).describe("Verzeichnis"),
2859
+ recursive: z.boolean().default(true).describe("Rekursiv"),
2860
+ extensions: z.string().optional().describe("Erweiterungen filtern"),
2861
+ min_size: z.number().int().min(0).default(1).describe("Mindestgröße in Bytes"),
2862
+ max_size: z.number().int().optional().describe("Maximale Größe in Bytes")
2863
+ },
2864
+ annotations: {
2865
+ readOnlyHint: true,
2866
+ destructiveHint: false,
2867
+ idempotentHint: true,
2868
+ openWorldHint: false
2869
+ }
2870
+ }, async (params) => {
2871
+ try {
2872
+ const dirPath = normalizePath(params.directory);
2873
+ if (!await pathExists(dirPath)) {
2874
+ return { isError: true, content: [{ type: "text", text: `❌ Verzeichnis nicht gefunden: ${dirPath}` }] };
2875
+ }
2876
+ const extFilter = params.extensions ? params.extensions.split(',').map(e => e.trim().toLowerCase()) : null;
2877
+ // Collect files with sizes
2878
+ const files = [];
2879
+ async function collectFiles(dir) {
2880
+ try {
2881
+ const entries = await fs.readdir(dir, { withFileTypes: true });
2882
+ for (const entry of entries) {
2883
+ const full = path.join(dir, entry.name);
2884
+ if (entry.isDirectory() && params.recursive) {
2885
+ if (!['node_modules', '.git', '$RECYCLE.BIN'].includes(entry.name)) {
2886
+ await collectFiles(full);
2887
+ }
2888
+ }
2889
+ else if (entry.isFile()) {
2890
+ if (!extFilter || extFilter.includes(path.extname(entry.name).toLowerCase())) {
2891
+ const stats = await fs.stat(full);
2892
+ if (stats.size >= params.min_size && (!params.max_size || stats.size <= params.max_size)) {
2893
+ files.push({ path: full, size: stats.size });
2894
+ }
2895
+ }
2896
+ }
2897
+ }
2898
+ }
2899
+ catch { /* skip inaccessible */ }
2900
+ }
2901
+ await collectFiles(dirPath);
2902
+ // Group by size first (quick filter)
2903
+ const sizeGroups = new Map();
2904
+ for (const f of files) {
2905
+ const group = sizeGroups.get(f.size) || [];
2906
+ group.push(f.path);
2907
+ sizeGroups.set(f.size, group);
2908
+ }
2909
+ // Hash only files with matching sizes
2910
+ const hashGroups = new Map();
2911
+ let hashedCount = 0;
2912
+ for (const [size, paths] of sizeGroups) {
2913
+ if (paths.length < 2)
2914
+ continue;
2915
+ for (const filePath of paths) {
2916
+ try {
2917
+ const content = await fs.readFile(filePath);
2918
+ const hash = crypto.createHash('sha256').update(content).digest('hex');
2919
+ hashedCount++;
2920
+ const group = hashGroups.get(hash) || { paths: [], size };
2921
+ group.paths.push(filePath);
2922
+ hashGroups.set(hash, group);
2923
+ }
2924
+ catch { /* skip unreadable */ }
2925
+ }
2926
+ }
2927
+ // Filter to actual duplicates
2928
+ const duplicates = [...hashGroups.values()].filter(g => g.paths.length > 1);
2929
+ const totalDuplicateFiles = duplicates.reduce((sum, g) => sum + g.paths.length - 1, 0);
2930
+ const totalWastedSpace = duplicates.reduce((sum, g) => sum + g.size * (g.paths.length - 1), 0);
2931
+ if (duplicates.length === 0) {
2932
+ return {
2933
+ content: [{ type: "text", text: `✅ Keine Duplikate gefunden. ${files.length} Dateien geprüft, ${hashedCount} gehasht.` }]
2934
+ };
2935
+ }
2936
+ const output = [
2937
+ `🔍 **Duplikate gefunden**`, '',
2938
+ `| | |`, `|---|---|`,
2939
+ `| Geprüfte Dateien | ${files.length} |`,
2940
+ `| Duplikat-Gruppen | ${duplicates.length} |`,
2941
+ `| Duplikate gesamt | ${totalDuplicateFiles} |`,
2942
+ `| Verschwendeter Platz | ${formatFileSize(totalWastedSpace)} |`
2943
+ ];
2944
+ for (let i = 0; i < Math.min(duplicates.length, 20); i++) {
2945
+ const group = duplicates[i];
2946
+ output.push('', `**Gruppe ${i + 1}** (${formatFileSize(group.size)}):`);
2947
+ for (const p of group.paths) {
2948
+ output.push(` 📄 ${path.relative(dirPath, p)}`);
2949
+ }
2950
+ }
2951
+ if (duplicates.length > 20) {
2952
+ output.push('', `... und ${duplicates.length - 20} weitere Gruppen`);
2953
+ }
2954
+ output.push('', `💡 Nutze \`fc_safe_delete\` zum sicheren Entfernen von Duplikaten.`);
2955
+ return { content: [{ type: "text", text: output.join('\n') }] };
2956
+ }
2957
+ catch (error) {
2958
+ return { isError: true, content: [{ type: "text", text: `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` }] };
2959
+ }
2960
+ });
2961
+ // ============================================================================
2962
+ // Tool: Markdown to HTML
2963
+ // ============================================================================
2964
+ server.registerTool("fc_md_to_html", {
2965
+ title: "Markdown zu HTML",
2966
+ description: `Konvertiert Markdown zu formatiertem HTML (druckbar als PDF).
2967
+
2968
+ Args:
2969
+ - input_path (string): Pfad zur Markdown-Datei
2970
+ - output_path (string): Pfad zur HTML-Ausgabe
2971
+ - title (string, optional): Dokumenttitel
2972
+
2973
+ Erzeugt eigenstaendiges HTML mit CSS-Styling, druckbar als PDF ueber den Browser.`,
2974
+ inputSchema: {
2975
+ input_path: z.string().min(1).describe("Markdown-Datei"),
2976
+ output_path: z.string().min(1).describe("HTML-Ausgabe"),
2977
+ title: z.string().optional().describe("Dokumenttitel")
2978
+ },
2979
+ annotations: {
2980
+ readOnlyHint: false,
2981
+ destructiveHint: false,
2982
+ idempotentHint: true,
2983
+ openWorldHint: false
2984
+ }
2985
+ }, async (params) => {
2986
+ try {
2987
+ const inputPath = normalizePath(params.input_path);
2988
+ const outputPath = normalizePath(params.output_path);
2989
+ if (!await pathExists(inputPath)) {
2990
+ return { isError: true, content: [{ type: "text", text: `❌ Datei nicht gefunden: ${inputPath}` }] };
2991
+ }
2992
+ const md = await fs.readFile(inputPath, "utf-8");
2993
+ const title = params.title || path.basename(inputPath, '.md');
2994
+ let html = md
2995
+ .replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
2996
+ .replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
2997
+ .replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
2998
+ .replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
2999
+ .replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
3000
+ .replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
3001
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
3002
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
3003
+ .replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
3004
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
3005
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
3006
+ .replace(/^---$/gm, '<hr>')
3007
+ .replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>')
3008
+ .replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
3009
+ .replace(/^\|(.+)\|$/gm, (match) => {
3010
+ const cells = match.split('|').filter(c => c.trim()).map(c => c.trim());
3011
+ if (cells.every(c => /^[-:]+$/.test(c)))
3012
+ return '';
3013
+ return '<tr>' + cells.map(c => `<td>${c}</td>`).join('') + '</tr>';
3014
+ })
3015
+ .replace(/(<tr>.*<\/tr>\n?)+/g, '<table>$&</table>')
3016
+ .replace(/^(?!<[hupolt]|<\/|$)(.+)$/gm, '<p>$1</p>');
3017
+ const fullHtml = `<!DOCTYPE html>
3018
+ <html lang="de">
3019
+ <head>
3020
+ <meta charset="UTF-8">
3021
+ <title>${title}</title>
3022
+ <style>
3023
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.6; color: #333; }
3024
+ h1, h2, h3 { border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
3025
+ code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
3026
+ pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }
3027
+ pre code { background: none; padding: 0; }
3028
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; }
3029
+ td, th { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
3030
+ tr:nth-child(even) { background: #f9f9f9; }
3031
+ hr { border: none; border-top: 2px solid #eee; margin: 2em 0; }
3032
+ @media print { body { max-width: none; margin: 0; } }
3033
+ </style>
3034
+ </head>
3035
+ <body>
3036
+ ${html}
3037
+ </body>
3038
+ </html>`;
3039
+ const outDir = path.dirname(outputPath);
3040
+ if (!await pathExists(outDir))
3041
+ await fs.mkdir(outDir, { recursive: true });
3042
+ await fs.writeFile(outputPath, fullHtml, "utf-8");
3043
+ const outStats = await fs.stat(outputPath);
3044
+ return {
3045
+ content: [{ type: "text", text: [
3046
+ `✅ **Markdown → HTML: ${path.basename(outputPath)}**`, '',
3047
+ `| | |`, `|---|---|`,
3048
+ `| Quelle | ${inputPath} |`,
3049
+ `| Ziel | ${outputPath} |`,
3050
+ `| Größe | ${formatFileSize(outStats.size)} |`, '',
3051
+ `💡 Öffne die HTML-Datei im Browser und drucke als PDF.`
3052
+ ].join('\n') }]
3053
+ };
3054
+ }
3055
+ catch (error) {
3056
+ return { isError: true, content: [{ type: "text", text: `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` }] };
3057
+ }
3058
+ });
3059
+ // ============================================================================
2026
3060
  // Server Startup
2027
3061
  // ============================================================================
2028
3062
  async function main() {