@utilix-tech/mcp 0.8.0 → 0.10.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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  Full release notes for all Utilix surfaces (browser tools, REST API, SDKs, MCP server) live at [utilix.tech/changelog](https://utilix.tech/changelog). This file tracks just this package.
4
4
 
5
+ ## 0.10.0
6
+ - Added Sitemap.xml Validator (`validate_sitemap`).
7
+
8
+ ## 0.9.0
9
+ - Added ID3 Tag Reader and .env.example Diff Checker.
10
+
11
+ ## 0.8.0
12
+ - Added WAV Audio Info and Readability Score Calculator.
13
+
14
+ ## 0.7.0
15
+ - Added EXIF Viewer and Commit Message Generator (AI).
16
+
17
+ ## 0.6.0
18
+ - Added PDF Metadata Reader and JWK Viewer.
19
+
20
+ ## 0.5.0
21
+ - Added HAR File Viewer and NDJSON Formatter/Validator.
22
+
5
23
  ## 0.4.1
6
24
  - Bumped the `@utilix-tech/sdk` dependency from `^0.2.0` to `^0.3.0` to match the SDK version actually in use, closing a gap where a future tool addition could have silently pinned to a stale SDK release.
7
25
  - Corrected repository/bug tracker links.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 104 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
3
+ MCP server exposing 107 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
4
4
 
5
5
  No API key required. All tools run locally. Requires Node.js 18 or later.
6
6
 
@@ -151,7 +151,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
151
151
  | `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
152
152
  | `px_to_units` | Convert pixels to rem, em, pt, and vw |
153
153
 
154
- ### API / Network (6 tools)
154
+ ### API / Network (7 tools)
155
155
  | Tool | Description |
156
156
  |------|-------------|
157
157
  | `decode_jwt` | Decode a JWT: header, payload, and expiry |
@@ -160,6 +160,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
160
160
  | `curl_to_code` | Convert a cURL command to fetch, axios, Python, Go, PHP, or Ruby |
161
161
  | `cors_headers` | Generate CORS response headers for a configuration |
162
162
  | `parse_har` | Parse a DevTools .har network export into requests and summary stats |
163
+ | `validate_sitemap` | Validate a sitemap.xml or sitemap index against the sitemaps.org protocol |
163
164
 
164
165
  ### Color (4 tools)
165
166
  | Tool | Description |
@@ -180,7 +181,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
180
181
  | `parse_env` | Parse a .env file and return all key-value pairs |
181
182
  | `parse_docker_image` | Parse a Docker image reference into its components |
182
183
 
183
- ### Data (5 tools)
184
+ ### Data (6 tools)
184
185
  | Tool | Description |
185
186
  |------|-------------|
186
187
  | `validate_yaml` | Validate YAML syntax |
@@ -188,6 +189,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
188
189
  | `xml_to_json` | Convert XML to JSON |
189
190
  | `parse_csv` | Parse a CSV string and return the data as JSON |
190
191
  | `format_ndjson` | Validate and pretty-print newline-delimited JSON (NDJSON) |
192
+ | `diff_env_example` | Diff a .env file against its .env.example template: flags keys missing from either side, plus empty placeholder values |
191
193
 
192
194
  ### CSS (2 tools)
193
195
  | Tool | Description |
@@ -242,10 +244,11 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
242
244
  |------|-------------|
243
245
  | `read_pdf_metadata` | Read title, author, dates, page count, and encryption flag from a PDF's trailer/Info dictionary: no PDF rendering library required |
244
246
 
245
- ### Audio (1 tool)
247
+ ### Audio (2 tools)
246
248
  | Tool | Description |
247
249
  |------|-------------|
248
250
  | `read_wav_info` | Read sample rate, channel count, bit depth, and duration from a WAV file's RIFF/fmt/data chunk headers: no audio library required |
251
+ | `read_id3_tags` | Read title, artist, album, year, genre, comment, and track number from an MP3's ID3 tags: no audio library required |
249
252
 
250
253
  ## Requirements
251
254
 
package/dist/index.js CHANGED
@@ -1479,6 +1479,106 @@ server.tool(
1479
1479
  return text(JSON.stringify(result, null, 2));
1480
1480
  }
1481
1481
  );
1482
+ var _SITEMAP_VALID_CHANGEFREQ = /* @__PURE__ */ new Set(["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]);
1483
+ function _sitemapLineNumber(xml, index) {
1484
+ let line = 1;
1485
+ for (let i = 0; i < index; i++) {
1486
+ if (xml[i] === "\n") line++;
1487
+ }
1488
+ return line;
1489
+ }
1490
+ function _extractSitemapTag(block, tag) {
1491
+ const m = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i"));
1492
+ return m ? m[1].trim() : void 0;
1493
+ }
1494
+ function _isValidW3CDatetime(s) {
1495
+ return /^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?)?)?$/.test(s);
1496
+ }
1497
+ function _isAbsoluteHttpUrl(s) {
1498
+ return /^https?:\/\//i.test(s);
1499
+ }
1500
+ function _validateSitemap(xml) {
1501
+ if (!xml || !xml.trim()) return { error: "Input is empty" };
1502
+ const trimmed = xml.trim();
1503
+ const issues = [];
1504
+ if (!/^\s*<\?xml/i.test(trimmed)) {
1505
+ issues.push({ severity: "warning", line: 1, message: 'Missing XML declaration (<?xml version="1.0" encoding="UTF-8"?>)' });
1506
+ }
1507
+ const isUrlset = /<urlset[\s>]/i.test(trimmed);
1508
+ const isSitemapIndex = /<sitemapindex[\s>]/i.test(trimmed);
1509
+ if (!isUrlset && !isSitemapIndex) {
1510
+ return { error: "Not a valid sitemap: root element must be <urlset> or <sitemapindex>" };
1511
+ }
1512
+ if (isUrlset && isSitemapIndex) {
1513
+ return { error: "Ambiguous file: contains both <urlset> and <sitemapindex> root elements" };
1514
+ }
1515
+ const type = isUrlset ? "urlset" : "sitemapindex";
1516
+ if (isUrlset && !/xmlns\s*=\s*["']http:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9["']/i.test(trimmed)) {
1517
+ issues.push({ severity: "warning", line: 1, message: "Missing or non-standard xmlns namespace (expected http://www.sitemaps.org/schemas/sitemap/0.9)" });
1518
+ }
1519
+ const blockTag = isUrlset ? "url" : "sitemap";
1520
+ const blockRe = new RegExp(`<${blockTag}>([\\s\\S]*?)</${blockTag}>`, "gi");
1521
+ const seenLocs = /* @__PURE__ */ new Set();
1522
+ const entries = [];
1523
+ let match;
1524
+ while ((match = blockRe.exec(trimmed)) !== null) {
1525
+ const block = match[1];
1526
+ const line = _sitemapLineNumber(trimmed, match.index);
1527
+ const loc = _extractSitemapTag(block, "loc");
1528
+ if (!loc) {
1529
+ issues.push({ severity: "error", line, message: `<${blockTag}> entry is missing required <loc>` });
1530
+ continue;
1531
+ }
1532
+ if (!_isAbsoluteHttpUrl(loc)) {
1533
+ issues.push({ severity: "error", line, message: `<loc>${loc}</loc> must be an absolute URL starting with http:// or https://` });
1534
+ }
1535
+ if (/&(?!amp;|lt;|gt;|apos;|quot;|#\d+;)/.test(loc)) {
1536
+ issues.push({ severity: "error", line, message: `<loc>${loc}</loc> contains an unescaped '&' (use &amp; instead)` });
1537
+ }
1538
+ if (seenLocs.has(loc)) {
1539
+ issues.push({ severity: "warning", line, message: `Duplicate <loc>${loc}</loc>` });
1540
+ }
1541
+ seenLocs.add(loc);
1542
+ const lastmod = _extractSitemapTag(block, "lastmod");
1543
+ if (lastmod !== void 0 && !_isValidW3CDatetime(lastmod)) {
1544
+ issues.push({ severity: "warning", line, message: `<lastmod>${lastmod}</lastmod> is not a valid W3C datetime` });
1545
+ }
1546
+ if (type === "urlset") {
1547
+ const changefreq = _extractSitemapTag(block, "changefreq");
1548
+ if (changefreq !== void 0 && !_SITEMAP_VALID_CHANGEFREQ.has(changefreq.toLowerCase())) {
1549
+ issues.push({ severity: "error", line, message: `<changefreq>${changefreq}</changefreq> must be one of: ${[..._SITEMAP_VALID_CHANGEFREQ].join(", ")}` });
1550
+ }
1551
+ const priority = _extractSitemapTag(block, "priority");
1552
+ if (priority !== void 0) {
1553
+ const p = Number(priority);
1554
+ if (Number.isNaN(p) || p < 0 || p > 1) {
1555
+ issues.push({ severity: "error", line, message: `<priority>${priority}</priority> must be a number between 0.0 and 1.0` });
1556
+ }
1557
+ }
1558
+ entries.push({ loc, lastmod, changefreq, priority, line });
1559
+ } else {
1560
+ entries.push({ loc, lastmod, line });
1561
+ }
1562
+ }
1563
+ if (entries.length === 0) {
1564
+ issues.push({ severity: "error", line: 1, message: `No <${blockTag}> entries found` });
1565
+ }
1566
+ if (type === "urlset" && entries.length > 5e4) {
1567
+ issues.push({ severity: "error", line: 1, message: `Sitemap contains ${entries.length} URLs, exceeding the 50,000 URL limit per the sitemaps.org spec` });
1568
+ }
1569
+ const valid = !issues.some((i) => i.severity === "error");
1570
+ return { type, urlCount: entries.length, entries, issues, valid };
1571
+ }
1572
+ server.tool(
1573
+ "validate_sitemap",
1574
+ "Validate a sitemap.xml (urlset) or sitemap index (sitemapindex) against the sitemaps.org protocol: required <loc>, absolute URLs, valid <changefreq>/<priority>/<lastmod> values, the 50,000-URL limit, and duplicate entries.",
1575
+ { xml: z.string().describe("The sitemap.xml or sitemap index XML contents") },
1576
+ async ({ xml }) => {
1577
+ const result = _validateSitemap(xml);
1578
+ if ("error" in result) return errText(result);
1579
+ return text(JSON.stringify(result, null, 2));
1580
+ }
1581
+ );
1482
1582
  server.tool(
1483
1583
  "format_ndjson",
1484
1584
  "Validate newline-delimited JSON (NDJSON/JSON Lines), report per-line errors, and pretty-print each valid line.",
@@ -2007,5 +2107,291 @@ function _scoreReadability(input) {
2007
2107
  server.tool("score_readability", "Score text with Flesch Reading Ease, Flesch-Kincaid Grade Level, and Gunning Fog Index, using a heuristic syllable counter.", { text: z.string() }, async ({ text: input }) => {
2008
2108
  return { content: [{ type: "text", text: JSON.stringify(_scoreReadability(input), null, 2) }] };
2009
2109
  });
2110
+ var _ID3V1_GENRES = [
2111
+ "Blues",
2112
+ "Classic Rock",
2113
+ "Country",
2114
+ "Dance",
2115
+ "Disco",
2116
+ "Funk",
2117
+ "Grunge",
2118
+ "Hip-Hop",
2119
+ "Jazz",
2120
+ "Metal",
2121
+ "New Age",
2122
+ "Oldies",
2123
+ "Other",
2124
+ "Pop",
2125
+ "R&B",
2126
+ "Rap",
2127
+ "Reggae",
2128
+ "Rock",
2129
+ "Techno",
2130
+ "Industrial",
2131
+ "Alternative",
2132
+ "Ska",
2133
+ "Death Metal",
2134
+ "Pranks",
2135
+ "Soundtrack",
2136
+ "Euro-Techno",
2137
+ "Ambient",
2138
+ "Trip-Hop",
2139
+ "Vocal",
2140
+ "Jazz+Funk",
2141
+ "Fusion",
2142
+ "Trance",
2143
+ "Classical",
2144
+ "Instrumental",
2145
+ "Acid",
2146
+ "House",
2147
+ "Game",
2148
+ "Sound Clip",
2149
+ "Gospel",
2150
+ "Noise",
2151
+ "AlternRock",
2152
+ "Bass",
2153
+ "Soul",
2154
+ "Punk",
2155
+ "Space",
2156
+ "Meditative",
2157
+ "Instrumental Pop",
2158
+ "Instrumental Rock",
2159
+ "Ethnic",
2160
+ "Gothic",
2161
+ "Darkwave",
2162
+ "Techno-Industrial",
2163
+ "Electronic",
2164
+ "Pop-Folk",
2165
+ "Eurodance",
2166
+ "Dream",
2167
+ "Southern Rock",
2168
+ "Comedy",
2169
+ "Cult",
2170
+ "Gangsta",
2171
+ "Top 40",
2172
+ "Christian Rap",
2173
+ "Pop/Funk",
2174
+ "Jungle",
2175
+ "Native American",
2176
+ "Cabaret",
2177
+ "New Wave",
2178
+ "Psychedelic",
2179
+ "Rave",
2180
+ "Showtunes",
2181
+ "Trailer",
2182
+ "Lo-Fi",
2183
+ "Tribal",
2184
+ "Acid Punk",
2185
+ "Acid Jazz",
2186
+ "Polka",
2187
+ "Retro",
2188
+ "Musical",
2189
+ "Rock & Roll",
2190
+ "Hard Rock"
2191
+ ];
2192
+ function _id3ReadSynchsafeUInt32(bytes, offset) {
2193
+ return (bytes[offset] & 127) << 21 | (bytes[offset + 1] & 127) << 14 | (bytes[offset + 2] & 127) << 7 | bytes[offset + 3] & 127;
2194
+ }
2195
+ function _id3ReadUInt32BE(bytes, offset) {
2196
+ return (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
2197
+ }
2198
+ function _decodeId3Text(frameBytes) {
2199
+ if (frameBytes.length === 0) return "";
2200
+ const encodingByte = frameBytes[0];
2201
+ const content = frameBytes.subarray(1);
2202
+ let out = "";
2203
+ if (encodingByte === 3) {
2204
+ out = Buffer.from(content).toString("utf8");
2205
+ } else if (encodingByte === 1 || encodingByte === 2) {
2206
+ let little = encodingByte === 1;
2207
+ let start = 0;
2208
+ if (encodingByte === 1 && content.length >= 2) {
2209
+ if (content[0] === 255 && content[1] === 254) {
2210
+ little = true;
2211
+ start = 2;
2212
+ } else if (content[0] === 254 && content[1] === 255) {
2213
+ little = false;
2214
+ start = 2;
2215
+ }
2216
+ }
2217
+ for (let i = start; i + 1 < content.length; i += 2) {
2218
+ const code = little ? content[i] | content[i + 1] << 8 : content[i] << 8 | content[i + 1];
2219
+ if (code === 0) break;
2220
+ out += String.fromCharCode(code);
2221
+ }
2222
+ } else {
2223
+ for (const b of content) {
2224
+ if (b === 0) break;
2225
+ out += String.fromCharCode(b);
2226
+ }
2227
+ }
2228
+ return out.replace(/\0+$/, "").trim();
2229
+ }
2230
+ function _readId3v2(bytes) {
2231
+ if (bytes.length < 10 || bytes[0] !== 73 || bytes[1] !== 68 || bytes[2] !== 51) return null;
2232
+ const majorVersion = bytes[3];
2233
+ const revision = bytes[4];
2234
+ const tagSize = _id3ReadSynchsafeUInt32(bytes, 6);
2235
+ const version = `ID3v2.${majorVersion}.${revision}`;
2236
+ if (majorVersion !== 3 && majorVersion !== 4) {
2237
+ return { version, frames: {}, frameCount: 0 };
2238
+ }
2239
+ const frames = {};
2240
+ let offset = 10;
2241
+ const end = Math.min(10 + tagSize, bytes.length);
2242
+ let frameCount = 0;
2243
+ while (offset + 10 <= end) {
2244
+ const frameId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
2245
+ if (frameId === "\0\0\0\0") break;
2246
+ const frameSize = majorVersion === 4 ? _id3ReadSynchsafeUInt32(bytes, offset + 4) : _id3ReadUInt32BE(bytes, offset + 4);
2247
+ const frameStart = offset + 10;
2248
+ if (frameSize <= 0 || frameStart + frameSize > bytes.length) break;
2249
+ const frameBytes = bytes.subarray(frameStart, frameStart + frameSize);
2250
+ if (frameId[0] === "T" && frameId !== "TXXX") {
2251
+ frames[frameId] = _decodeId3Text(frameBytes);
2252
+ } else if (frameId === "COMM" && frameBytes.length > 4) {
2253
+ const rebuilt = new Uint8Array(frameBytes.length - 3);
2254
+ rebuilt[0] = frameBytes[0];
2255
+ rebuilt.set(frameBytes.subarray(4), 1);
2256
+ const decoded = _decodeId3Text(rebuilt);
2257
+ const parts = decoded.split("\0");
2258
+ frames["COMM"] = (parts[parts.length - 1] || decoded).trim();
2259
+ }
2260
+ frameCount++;
2261
+ offset = frameStart + frameSize;
2262
+ }
2263
+ return { version, frames, frameCount };
2264
+ }
2265
+ function _readId3v1(bytes) {
2266
+ if (bytes.length < 128) return null;
2267
+ const start = bytes.length - 128;
2268
+ if (String.fromCharCode(bytes[start], bytes[start + 1], bytes[start + 2]) !== "TAG") return null;
2269
+ const readField = (offset, length) => {
2270
+ let s = "";
2271
+ for (let i = 0; i < length; i++) {
2272
+ const b = bytes[start + offset + i];
2273
+ if (b === 0) break;
2274
+ s += String.fromCharCode(b);
2275
+ }
2276
+ return s.trim();
2277
+ };
2278
+ const title = readField(3, 30);
2279
+ const artist = readField(33, 30);
2280
+ const album = readField(63, 30);
2281
+ const year = readField(93, 4);
2282
+ const isV11 = bytes[start + 125] === 0 && bytes[start + 126] !== 0;
2283
+ const comment = readField(97, isV11 ? 28 : 30);
2284
+ const genreIndex = bytes[start + 127];
2285
+ const genre = _ID3V1_GENRES[genreIndex] ?? `Unknown (${genreIndex})`;
2286
+ const result = { genre };
2287
+ if (title) result.title = title;
2288
+ if (artist) result.artist = artist;
2289
+ if (album) result.album = album;
2290
+ if (year) result.year = year;
2291
+ if (comment) result.comment = comment;
2292
+ if (isV11) result.trackNumber = String(bytes[start + 126]);
2293
+ return result;
2294
+ }
2295
+ function _readId3Tags(bytes) {
2296
+ const v2 = _readId3v2(bytes);
2297
+ const v1 = _readId3v1(bytes);
2298
+ if (v2 && Object.keys(v2.frames).length > 0) {
2299
+ const f = v2.frames;
2300
+ const result = { version: v2.version, frameCount: v2.frameCount };
2301
+ if (f["TIT2"]) result.title = f["TIT2"];
2302
+ if (f["TPE1"]) result.artist = f["TPE1"];
2303
+ if (f["TALB"]) result.album = f["TALB"];
2304
+ const year = f["TYER"] || f["TDRC"];
2305
+ if (year) result.year = year;
2306
+ if (f["TCON"]) result.genre = f["TCON"];
2307
+ if (f["COMM"]) result.comment = f["COMM"];
2308
+ if (f["TRCK"]) result.trackNumber = f["TRCK"];
2309
+ return result;
2310
+ }
2311
+ if (v1) {
2312
+ return { version: v1.trackNumber !== void 0 ? "ID3v1.1" : "ID3v1", ...v1 };
2313
+ }
2314
+ if (v2) {
2315
+ return { error: `Unsupported ID3 tag version: ${v2.version} (only ID3v2.3, ID3v2.4, and ID3v1/1.1 are supported)` };
2316
+ }
2317
+ return { error: "No ID3v1 or ID3v2.3/2.4 tags found in this file" };
2318
+ }
2319
+ server.tool("read_id3_tags", "Read ID3 tags (title, artist, album, year, genre, comment, track number) directly from an MP3 file's bytes. Prefers an ID3v2.3/2.4 header; falls back to a classic ID3v1/1.1 128-byte trailer. No audio library required.", { mp3_base64: z.string().describe("Base64-encoded MP3 file bytes, optionally prefixed with a data: URL scheme") }, async ({ mp3_base64 }) => {
2320
+ const bytes = new Uint8Array(Buffer.from(mp3_base64.replace(/^data:.*;base64,/, ""), "base64"));
2321
+ const result = _readId3Tags(bytes);
2322
+ if ("error" in result) return errText(result);
2323
+ return text(JSON.stringify(result, null, 2));
2324
+ });
2325
+ function _parseEnvForDiff(input) {
2326
+ const entries = [];
2327
+ const errors = [];
2328
+ const rawLines = input.split("\n");
2329
+ const lines = [];
2330
+ let i = 0;
2331
+ while (i < rawLines.length) {
2332
+ let line = rawLines[i];
2333
+ while (line.endsWith("\\") && i + 1 < rawLines.length) {
2334
+ line = line.slice(0, -1) + rawLines[i + 1];
2335
+ i++;
2336
+ }
2337
+ lines.push(line);
2338
+ i++;
2339
+ }
2340
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
2341
+ const lineNum = lineIdx + 1;
2342
+ const trimmed = lines[lineIdx].trim();
2343
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
2344
+ const eqIdx = trimmed.indexOf("=");
2345
+ if (eqIdx === -1) {
2346
+ errors.push(`Line ${lineNum}: missing '='`);
2347
+ continue;
2348
+ }
2349
+ const key = trimmed.slice(0, eqIdx).trim();
2350
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
2351
+ errors.push(`Line ${lineNum}: Invalid key: '${key}'`);
2352
+ continue;
2353
+ }
2354
+ const rest = trimmed.slice(eqIdx + 1);
2355
+ let value = "";
2356
+ if (rest.startsWith('"')) {
2357
+ const closeIdx = rest.indexOf('"', 1);
2358
+ if (closeIdx === -1) {
2359
+ errors.push(`Line ${lineNum}: Unclosed double quote for key '${key}'`);
2360
+ continue;
2361
+ }
2362
+ value = rest.slice(1, closeIdx);
2363
+ } else if (rest.startsWith("'")) {
2364
+ const closeIdx = rest.indexOf("'", 1);
2365
+ if (closeIdx === -1) {
2366
+ errors.push(`Line ${lineNum}: Unclosed single quote for key '${key}'`);
2367
+ continue;
2368
+ }
2369
+ value = rest.slice(1, closeIdx);
2370
+ } else {
2371
+ const hashIdx = rest.indexOf("#");
2372
+ value = (hashIdx !== -1 ? rest.slice(0, hashIdx) : rest).trim();
2373
+ }
2374
+ entries.push({ key, value });
2375
+ }
2376
+ return { entries, errors };
2377
+ }
2378
+ function _diffEnvExample(envInput, exampleInput) {
2379
+ const envParsed = _parseEnvForDiff(envInput);
2380
+ if (envParsed.errors.length > 0) return { error: `.env: ${envParsed.errors.join("; ")}` };
2381
+ const exampleParsed = _parseEnvForDiff(exampleInput);
2382
+ if (exampleParsed.errors.length > 0) return { error: `.env.example: ${exampleParsed.errors.join("; ")}` };
2383
+ const envKeys = new Set(envParsed.entries.map((e) => e.key));
2384
+ const exampleValues = new Map(exampleParsed.entries.map((e) => [e.key, e.value]));
2385
+ const missingInExample = [...envKeys].filter((k) => !exampleValues.has(k)).sort();
2386
+ const missingInEnv = [...exampleValues.keys()].filter((k) => !envKeys.has(k)).sort();
2387
+ const emptyValueInExample = [...exampleValues.entries()].filter(([k, v]) => envKeys.has(k) && v === "").map(([k]) => k).sort();
2388
+ const keysInBoth = [...envKeys].filter((k) => exampleValues.has(k)).length;
2389
+ return { missingInExample, missingInEnv, emptyValueInExample, keysInBoth };
2390
+ }
2391
+ server.tool("diff_env_example", "Diff a .env file against its .env.example template: flags keys missing from either side, plus keys present in both with an empty placeholder value in the example.", { env: z.string().describe("The .env file contents"), example: z.string().describe("The .env.example file contents") }, async ({ env, example }) => {
2392
+ const result = _diffEnvExample(env, example);
2393
+ if ("error" in result) return errText(result);
2394
+ return text(JSON.stringify(result, null, 2));
2395
+ });
2010
2396
  var transport = new StdioServerTransport();
2011
2397
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.8.0",
4
- "description": "MCP server exposing 104 Utilix developer tools to Claude, Cursor, VS Code Copilot, and any MCP-compatible AI assistant.",
3
+ "version": "0.10.0",
4
+ "description": "MCP server exposing 107 Utilix developer tools to Claude, Cursor, VS Code Copilot, and any MCP-compatible AI assistant.",
5
5
  "author": "Utilix <hello@utilix.tech>",
6
6
  "license": "MIT",
7
7
  "homepage": "https://utilix.tech/mcp",