@utilix-tech/mcp 0.7.0 → 0.9.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,21 @@
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.9.0
6
+ - Added ID3 Tag Reader and .env.example Diff Checker.
7
+
8
+ ## 0.8.0
9
+ - Added WAV Audio Info and Readability Score Calculator.
10
+
11
+ ## 0.7.0
12
+ - Added EXIF Viewer and Commit Message Generator (AI).
13
+
14
+ ## 0.6.0
15
+ - Added PDF Metadata Reader and JWK Viewer.
16
+
17
+ ## 0.5.0
18
+ - Added HAR File Viewer and NDJSON Formatter/Validator.
19
+
5
20
  ## 0.4.1
6
21
  - 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
22
  - Corrected repository/bug tracker links.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 102 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
3
+ MCP server exposing 106 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
 
@@ -121,7 +121,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
121
121
  | `generate_qr` | Generate a QR code as an SVG string |
122
122
  | `generate_random_data` | Generate random mock data: names, emails, UUIDs, IPs, etc. |
123
123
 
124
- ### Text (10 tools)
124
+ ### Text (11 tools)
125
125
  | Tool | Description |
126
126
  |------|-------------|
127
127
  | `convert_case` | Convert text case: camelCase, snake_case, PascalCase, etc. |
@@ -134,6 +134,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
134
134
  | `html_to_markdown` | Convert HTML to Markdown |
135
135
  | `line_operations` | Sort, deduplicate, reverse, shuffle, trim, or number lines |
136
136
  | `detect_passive_voice` | Flag sentences written in passive voice, e.g. "was written", "were approved by the team" |
137
+ | `score_readability` | Score text with Flesch Reading Ease, Flesch-Kincaid Grade Level, and Gunning Fog Index |
137
138
 
138
139
  ### Time (5 tools)
139
140
  | Tool | Description |
@@ -179,7 +180,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
179
180
  | `parse_env` | Parse a .env file and return all key-value pairs |
180
181
  | `parse_docker_image` | Parse a Docker image reference into its components |
181
182
 
182
- ### Data (5 tools)
183
+ ### Data (6 tools)
183
184
  | Tool | Description |
184
185
  |------|-------------|
185
186
  | `validate_yaml` | Validate YAML syntax |
@@ -187,6 +188,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
187
188
  | `xml_to_json` | Convert XML to JSON |
188
189
  | `parse_csv` | Parse a CSV string and return the data as JSON |
189
190
  | `format_ndjson` | Validate and pretty-print newline-delimited JSON (NDJSON) |
191
+ | `diff_env_example` | Diff a .env file against its .env.example template: flags keys missing from either side, plus empty placeholder values |
190
192
 
191
193
  ### CSS (2 tools)
192
194
  | Tool | Description |
@@ -241,6 +243,12 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
241
243
  |------|-------------|
242
244
  | `read_pdf_metadata` | Read title, author, dates, page count, and encryption flag from a PDF's trailer/Info dictionary: no PDF rendering library required |
243
245
 
246
+ ### Audio (2 tools)
247
+ | Tool | Description |
248
+ |------|-------------|
249
+ | `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 |
250
+ | `read_id3_tags` | Read title, artist, album, year, genre, comment, and track number from an MP3's ID3 tags: no audio library required |
251
+
244
252
  ## Requirements
245
253
 
246
254
  - Node.js 18+
package/dist/index.js CHANGED
@@ -1876,5 +1876,422 @@ server.tool("parse_jwks", "Parse a JSON Web Key Set (or a single bare JWK) and s
1876
1876
  if ("error" in result) return errText(result);
1877
1877
  return text(JSON.stringify(result, null, 2));
1878
1878
  });
1879
+ var _WAV_AUDIO_FORMAT_LABELS = { 1: "PCM", 3: "IEEE float", 6: "A-law", 7: "mu-law", 65534: "Extensible" };
1880
+ function _readWavAscii4(bytes, offset) {
1881
+ return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
1882
+ }
1883
+ function _readWavInfo(bytes) {
1884
+ if (bytes.length < 12) return { error: "File is too small to be a valid WAV file" };
1885
+ if (_readWavAscii4(bytes, 0) !== "RIFF") return { error: 'Not a RIFF file (missing "RIFF" header)' };
1886
+ if (_readWavAscii4(bytes, 8) !== "WAVE") return { error: 'Not a WAVE file (missing "WAVE" format marker)' };
1887
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1888
+ const fileSize = view.getUint32(4, true) + 8;
1889
+ let offset = 12;
1890
+ let fmt = null;
1891
+ let dataSize = null;
1892
+ while (offset + 8 <= bytes.length) {
1893
+ const chunkId = _readWavAscii4(bytes, offset);
1894
+ const chunkSize = view.getUint32(offset + 4, true);
1895
+ const dataOffset = offset + 8;
1896
+ if (chunkId === "fmt ") {
1897
+ if (dataOffset + 16 > bytes.length) return { error: 'Truncated "fmt " chunk' };
1898
+ fmt = {
1899
+ audioFormat: view.getUint16(dataOffset, true),
1900
+ channels: view.getUint16(dataOffset + 2, true),
1901
+ sampleRate: view.getUint32(dataOffset + 4, true),
1902
+ byteRate: view.getUint32(dataOffset + 8, true),
1903
+ blockAlign: view.getUint16(dataOffset + 12, true),
1904
+ bitsPerSample: view.getUint16(dataOffset + 14, true)
1905
+ };
1906
+ } else if (chunkId === "data") {
1907
+ dataSize = Math.min(chunkSize, bytes.length - dataOffset);
1908
+ }
1909
+ offset = dataOffset + chunkSize + chunkSize % 2;
1910
+ if (fmt && dataSize !== null) break;
1911
+ }
1912
+ if (!fmt) return { error: 'No "fmt " chunk found in this WAV file' };
1913
+ if (dataSize === null) return { error: 'No "data" chunk found in this WAV file' };
1914
+ if (fmt.byteRate <= 0) return { error: 'Invalid byte rate in "fmt " chunk' };
1915
+ const durationSeconds = Math.round(dataSize / fmt.byteRate * 1e3) / 1e3;
1916
+ return {
1917
+ audioFormat: fmt.audioFormat,
1918
+ audioFormatLabel: _WAV_AUDIO_FORMAT_LABELS[fmt.audioFormat] ?? `Unknown (0x${fmt.audioFormat.toString(16)})`,
1919
+ channels: fmt.channels,
1920
+ sampleRate: fmt.sampleRate,
1921
+ byteRate: fmt.byteRate,
1922
+ blockAlign: fmt.blockAlign,
1923
+ bitsPerSample: fmt.bitsPerSample,
1924
+ dataSize,
1925
+ durationSeconds,
1926
+ fileSize
1927
+ };
1928
+ }
1929
+ server.tool("read_wav_info", "Read sample rate, channel count, bit depth, and duration directly from a WAV file's RIFF/fmt/data chunk headers. No audio library required.", { wav_base64: z.string().describe("Base64-encoded WAV file bytes, optionally prefixed with a data: URL scheme") }, async ({ wav_base64 }) => {
1930
+ const bytes = new Uint8Array(Buffer.from(wav_base64.replace(/^data:.*;base64,/, ""), "base64"));
1931
+ const result = _readWavInfo(bytes);
1932
+ if ("error" in result) return errText(result);
1933
+ return text(JSON.stringify(result, null, 2));
1934
+ });
1935
+ function _readabilitySplitIntoSentences(text2) {
1936
+ const matches = text2.match(/[^.!?]+[.!?]*/g);
1937
+ if (!matches) return [];
1938
+ return matches.map((s) => s.trim()).filter((s) => s.length > 0);
1939
+ }
1940
+ function _countReadabilitySyllables(word) {
1941
+ const w = word.toLowerCase().replace(/[^a-z]/g, "");
1942
+ if (w.length === 0) return 0;
1943
+ if (w.length <= 3) return 1;
1944
+ let count = 0;
1945
+ let prevWasVowel = false;
1946
+ for (const ch of w) {
1947
+ const isVowel = "aeiouy".includes(ch);
1948
+ if (isVowel && !prevWasVowel) count++;
1949
+ prevWasVowel = isVowel;
1950
+ }
1951
+ if (w.endsWith("e") && !w.endsWith("le") && count > 1) count--;
1952
+ if (w.endsWith("le") && w.length > 2 && !"aeiouy".includes(w[w.length - 3])) count++;
1953
+ return Math.max(count, 1);
1954
+ }
1955
+ function _readabilityGradeLabel(fleschReadingEase) {
1956
+ if (fleschReadingEase >= 90) return "Very easy (5th grade)";
1957
+ if (fleschReadingEase >= 80) return "Easy (6th grade)";
1958
+ if (fleschReadingEase >= 70) return "Fairly easy (7th grade)";
1959
+ if (fleschReadingEase >= 60) return "Standard (8th-9th grade)";
1960
+ if (fleschReadingEase >= 50) return "Fairly difficult (10th-12th grade)";
1961
+ if (fleschReadingEase >= 30) return "Difficult (college)";
1962
+ return "Very difficult (college graduate)";
1963
+ }
1964
+ function _round1Readability(n) {
1965
+ return Math.round(n * 10) / 10;
1966
+ }
1967
+ function _scoreReadability(input) {
1968
+ const sentences = _readabilitySplitIntoSentences(input);
1969
+ const words = input.match(/[a-zA-Z']+/g) ?? [];
1970
+ const sentenceCount = Math.max(sentences.length, 1);
1971
+ const wordCount = words.length;
1972
+ const syllableCounts = words.map(_countReadabilitySyllables);
1973
+ const syllableCount = syllableCounts.reduce((a, b) => a + b, 0);
1974
+ const complexWordCount = syllableCounts.filter((c) => c >= 3).length;
1975
+ if (wordCount === 0) {
1976
+ return {
1977
+ wordCount: 0,
1978
+ sentenceCount: 0,
1979
+ syllableCount: 0,
1980
+ complexWordCount: 0,
1981
+ avgWordsPerSentence: 0,
1982
+ avgSyllablesPerWord: 0,
1983
+ fleschReadingEase: 0,
1984
+ fleschKincaidGrade: 0,
1985
+ gunningFog: 0,
1986
+ readingLevel: "No text to score"
1987
+ };
1988
+ }
1989
+ const avgWordsPerSentence = wordCount / sentenceCount;
1990
+ const avgSyllablesPerWord = syllableCount / wordCount;
1991
+ const fleschReadingEase = _round1Readability(206.835 - 1.015 * avgWordsPerSentence - 84.6 * avgSyllablesPerWord);
1992
+ const fleschKincaidGrade = _round1Readability(0.39 * avgWordsPerSentence + 11.8 * avgSyllablesPerWord - 15.59);
1993
+ const gunningFog = _round1Readability(0.4 * (avgWordsPerSentence + 100 * (complexWordCount / wordCount)));
1994
+ return {
1995
+ wordCount,
1996
+ sentenceCount: sentences.length,
1997
+ syllableCount,
1998
+ complexWordCount,
1999
+ avgWordsPerSentence: _round1Readability(avgWordsPerSentence),
2000
+ avgSyllablesPerWord: _round1Readability(avgSyllablesPerWord),
2001
+ fleschReadingEase,
2002
+ fleschKincaidGrade,
2003
+ gunningFog,
2004
+ readingLevel: _readabilityGradeLabel(fleschReadingEase)
2005
+ };
2006
+ }
2007
+ 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
+ return { content: [{ type: "text", text: JSON.stringify(_scoreReadability(input), null, 2) }] };
2009
+ });
2010
+ var _ID3V1_GENRES = [
2011
+ "Blues",
2012
+ "Classic Rock",
2013
+ "Country",
2014
+ "Dance",
2015
+ "Disco",
2016
+ "Funk",
2017
+ "Grunge",
2018
+ "Hip-Hop",
2019
+ "Jazz",
2020
+ "Metal",
2021
+ "New Age",
2022
+ "Oldies",
2023
+ "Other",
2024
+ "Pop",
2025
+ "R&B",
2026
+ "Rap",
2027
+ "Reggae",
2028
+ "Rock",
2029
+ "Techno",
2030
+ "Industrial",
2031
+ "Alternative",
2032
+ "Ska",
2033
+ "Death Metal",
2034
+ "Pranks",
2035
+ "Soundtrack",
2036
+ "Euro-Techno",
2037
+ "Ambient",
2038
+ "Trip-Hop",
2039
+ "Vocal",
2040
+ "Jazz+Funk",
2041
+ "Fusion",
2042
+ "Trance",
2043
+ "Classical",
2044
+ "Instrumental",
2045
+ "Acid",
2046
+ "House",
2047
+ "Game",
2048
+ "Sound Clip",
2049
+ "Gospel",
2050
+ "Noise",
2051
+ "AlternRock",
2052
+ "Bass",
2053
+ "Soul",
2054
+ "Punk",
2055
+ "Space",
2056
+ "Meditative",
2057
+ "Instrumental Pop",
2058
+ "Instrumental Rock",
2059
+ "Ethnic",
2060
+ "Gothic",
2061
+ "Darkwave",
2062
+ "Techno-Industrial",
2063
+ "Electronic",
2064
+ "Pop-Folk",
2065
+ "Eurodance",
2066
+ "Dream",
2067
+ "Southern Rock",
2068
+ "Comedy",
2069
+ "Cult",
2070
+ "Gangsta",
2071
+ "Top 40",
2072
+ "Christian Rap",
2073
+ "Pop/Funk",
2074
+ "Jungle",
2075
+ "Native American",
2076
+ "Cabaret",
2077
+ "New Wave",
2078
+ "Psychedelic",
2079
+ "Rave",
2080
+ "Showtunes",
2081
+ "Trailer",
2082
+ "Lo-Fi",
2083
+ "Tribal",
2084
+ "Acid Punk",
2085
+ "Acid Jazz",
2086
+ "Polka",
2087
+ "Retro",
2088
+ "Musical",
2089
+ "Rock & Roll",
2090
+ "Hard Rock"
2091
+ ];
2092
+ function _id3ReadSynchsafeUInt32(bytes, offset) {
2093
+ return (bytes[offset] & 127) << 21 | (bytes[offset + 1] & 127) << 14 | (bytes[offset + 2] & 127) << 7 | bytes[offset + 3] & 127;
2094
+ }
2095
+ function _id3ReadUInt32BE(bytes, offset) {
2096
+ return (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
2097
+ }
2098
+ function _decodeId3Text(frameBytes) {
2099
+ if (frameBytes.length === 0) return "";
2100
+ const encodingByte = frameBytes[0];
2101
+ const content = frameBytes.subarray(1);
2102
+ let out = "";
2103
+ if (encodingByte === 3) {
2104
+ out = Buffer.from(content).toString("utf8");
2105
+ } else if (encodingByte === 1 || encodingByte === 2) {
2106
+ let little = encodingByte === 1;
2107
+ let start = 0;
2108
+ if (encodingByte === 1 && content.length >= 2) {
2109
+ if (content[0] === 255 && content[1] === 254) {
2110
+ little = true;
2111
+ start = 2;
2112
+ } else if (content[0] === 254 && content[1] === 255) {
2113
+ little = false;
2114
+ start = 2;
2115
+ }
2116
+ }
2117
+ for (let i = start; i + 1 < content.length; i += 2) {
2118
+ const code = little ? content[i] | content[i + 1] << 8 : content[i] << 8 | content[i + 1];
2119
+ if (code === 0) break;
2120
+ out += String.fromCharCode(code);
2121
+ }
2122
+ } else {
2123
+ for (const b of content) {
2124
+ if (b === 0) break;
2125
+ out += String.fromCharCode(b);
2126
+ }
2127
+ }
2128
+ return out.replace(/\0+$/, "").trim();
2129
+ }
2130
+ function _readId3v2(bytes) {
2131
+ if (bytes.length < 10 || bytes[0] !== 73 || bytes[1] !== 68 || bytes[2] !== 51) return null;
2132
+ const majorVersion = bytes[3];
2133
+ const revision = bytes[4];
2134
+ const tagSize = _id3ReadSynchsafeUInt32(bytes, 6);
2135
+ const version = `ID3v2.${majorVersion}.${revision}`;
2136
+ if (majorVersion !== 3 && majorVersion !== 4) {
2137
+ return { version, frames: {}, frameCount: 0 };
2138
+ }
2139
+ const frames = {};
2140
+ let offset = 10;
2141
+ const end = Math.min(10 + tagSize, bytes.length);
2142
+ let frameCount = 0;
2143
+ while (offset + 10 <= end) {
2144
+ const frameId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
2145
+ if (frameId === "\0\0\0\0") break;
2146
+ const frameSize = majorVersion === 4 ? _id3ReadSynchsafeUInt32(bytes, offset + 4) : _id3ReadUInt32BE(bytes, offset + 4);
2147
+ const frameStart = offset + 10;
2148
+ if (frameSize <= 0 || frameStart + frameSize > bytes.length) break;
2149
+ const frameBytes = bytes.subarray(frameStart, frameStart + frameSize);
2150
+ if (frameId[0] === "T" && frameId !== "TXXX") {
2151
+ frames[frameId] = _decodeId3Text(frameBytes);
2152
+ } else if (frameId === "COMM" && frameBytes.length > 4) {
2153
+ const rebuilt = new Uint8Array(frameBytes.length - 3);
2154
+ rebuilt[0] = frameBytes[0];
2155
+ rebuilt.set(frameBytes.subarray(4), 1);
2156
+ const decoded = _decodeId3Text(rebuilt);
2157
+ const parts = decoded.split("\0");
2158
+ frames["COMM"] = (parts[parts.length - 1] || decoded).trim();
2159
+ }
2160
+ frameCount++;
2161
+ offset = frameStart + frameSize;
2162
+ }
2163
+ return { version, frames, frameCount };
2164
+ }
2165
+ function _readId3v1(bytes) {
2166
+ if (bytes.length < 128) return null;
2167
+ const start = bytes.length - 128;
2168
+ if (String.fromCharCode(bytes[start], bytes[start + 1], bytes[start + 2]) !== "TAG") return null;
2169
+ const readField = (offset, length) => {
2170
+ let s = "";
2171
+ for (let i = 0; i < length; i++) {
2172
+ const b = bytes[start + offset + i];
2173
+ if (b === 0) break;
2174
+ s += String.fromCharCode(b);
2175
+ }
2176
+ return s.trim();
2177
+ };
2178
+ const title = readField(3, 30);
2179
+ const artist = readField(33, 30);
2180
+ const album = readField(63, 30);
2181
+ const year = readField(93, 4);
2182
+ const isV11 = bytes[start + 125] === 0 && bytes[start + 126] !== 0;
2183
+ const comment = readField(97, isV11 ? 28 : 30);
2184
+ const genreIndex = bytes[start + 127];
2185
+ const genre = _ID3V1_GENRES[genreIndex] ?? `Unknown (${genreIndex})`;
2186
+ const result = { genre };
2187
+ if (title) result.title = title;
2188
+ if (artist) result.artist = artist;
2189
+ if (album) result.album = album;
2190
+ if (year) result.year = year;
2191
+ if (comment) result.comment = comment;
2192
+ if (isV11) result.trackNumber = String(bytes[start + 126]);
2193
+ return result;
2194
+ }
2195
+ function _readId3Tags(bytes) {
2196
+ const v2 = _readId3v2(bytes);
2197
+ const v1 = _readId3v1(bytes);
2198
+ if (v2 && Object.keys(v2.frames).length > 0) {
2199
+ const f = v2.frames;
2200
+ const result = { version: v2.version, frameCount: v2.frameCount };
2201
+ if (f["TIT2"]) result.title = f["TIT2"];
2202
+ if (f["TPE1"]) result.artist = f["TPE1"];
2203
+ if (f["TALB"]) result.album = f["TALB"];
2204
+ const year = f["TYER"] || f["TDRC"];
2205
+ if (year) result.year = year;
2206
+ if (f["TCON"]) result.genre = f["TCON"];
2207
+ if (f["COMM"]) result.comment = f["COMM"];
2208
+ if (f["TRCK"]) result.trackNumber = f["TRCK"];
2209
+ return result;
2210
+ }
2211
+ if (v1) {
2212
+ return { version: v1.trackNumber !== void 0 ? "ID3v1.1" : "ID3v1", ...v1 };
2213
+ }
2214
+ if (v2) {
2215
+ return { error: `Unsupported ID3 tag version: ${v2.version} (only ID3v2.3, ID3v2.4, and ID3v1/1.1 are supported)` };
2216
+ }
2217
+ return { error: "No ID3v1 or ID3v2.3/2.4 tags found in this file" };
2218
+ }
2219
+ 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 }) => {
2220
+ const bytes = new Uint8Array(Buffer.from(mp3_base64.replace(/^data:.*;base64,/, ""), "base64"));
2221
+ const result = _readId3Tags(bytes);
2222
+ if ("error" in result) return errText(result);
2223
+ return text(JSON.stringify(result, null, 2));
2224
+ });
2225
+ function _parseEnvForDiff(input) {
2226
+ const entries = [];
2227
+ const errors = [];
2228
+ const rawLines = input.split("\n");
2229
+ const lines = [];
2230
+ let i = 0;
2231
+ while (i < rawLines.length) {
2232
+ let line = rawLines[i];
2233
+ while (line.endsWith("\\") && i + 1 < rawLines.length) {
2234
+ line = line.slice(0, -1) + rawLines[i + 1];
2235
+ i++;
2236
+ }
2237
+ lines.push(line);
2238
+ i++;
2239
+ }
2240
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
2241
+ const lineNum = lineIdx + 1;
2242
+ const trimmed = lines[lineIdx].trim();
2243
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
2244
+ const eqIdx = trimmed.indexOf("=");
2245
+ if (eqIdx === -1) {
2246
+ errors.push(`Line ${lineNum}: missing '='`);
2247
+ continue;
2248
+ }
2249
+ const key = trimmed.slice(0, eqIdx).trim();
2250
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
2251
+ errors.push(`Line ${lineNum}: Invalid key: '${key}'`);
2252
+ continue;
2253
+ }
2254
+ const rest = trimmed.slice(eqIdx + 1);
2255
+ let value = "";
2256
+ if (rest.startsWith('"')) {
2257
+ const closeIdx = rest.indexOf('"', 1);
2258
+ if (closeIdx === -1) {
2259
+ errors.push(`Line ${lineNum}: Unclosed double quote for key '${key}'`);
2260
+ continue;
2261
+ }
2262
+ value = rest.slice(1, closeIdx);
2263
+ } else if (rest.startsWith("'")) {
2264
+ const closeIdx = rest.indexOf("'", 1);
2265
+ if (closeIdx === -1) {
2266
+ errors.push(`Line ${lineNum}: Unclosed single quote for key '${key}'`);
2267
+ continue;
2268
+ }
2269
+ value = rest.slice(1, closeIdx);
2270
+ } else {
2271
+ const hashIdx = rest.indexOf("#");
2272
+ value = (hashIdx !== -1 ? rest.slice(0, hashIdx) : rest).trim();
2273
+ }
2274
+ entries.push({ key, value });
2275
+ }
2276
+ return { entries, errors };
2277
+ }
2278
+ function _diffEnvExample(envInput, exampleInput) {
2279
+ const envParsed = _parseEnvForDiff(envInput);
2280
+ if (envParsed.errors.length > 0) return { error: `.env: ${envParsed.errors.join("; ")}` };
2281
+ const exampleParsed = _parseEnvForDiff(exampleInput);
2282
+ if (exampleParsed.errors.length > 0) return { error: `.env.example: ${exampleParsed.errors.join("; ")}` };
2283
+ const envKeys = new Set(envParsed.entries.map((e) => e.key));
2284
+ const exampleValues = new Map(exampleParsed.entries.map((e) => [e.key, e.value]));
2285
+ const missingInExample = [...envKeys].filter((k) => !exampleValues.has(k)).sort();
2286
+ const missingInEnv = [...exampleValues.keys()].filter((k) => !envKeys.has(k)).sort();
2287
+ const emptyValueInExample = [...exampleValues.entries()].filter(([k, v]) => envKeys.has(k) && v === "").map(([k]) => k).sort();
2288
+ const keysInBoth = [...envKeys].filter((k) => exampleValues.has(k)).length;
2289
+ return { missingInExample, missingInEnv, emptyValueInExample, keysInBoth };
2290
+ }
2291
+ 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 }) => {
2292
+ const result = _diffEnvExample(env, example);
2293
+ if ("error" in result) return errText(result);
2294
+ return text(JSON.stringify(result, null, 2));
2295
+ });
1879
2296
  var transport = new StdioServerTransport();
1880
2297
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.7.0",
4
- "description": "MCP server exposing 102 Utilix developer tools to Claude, Cursor, VS Code Copilot, and any MCP-compatible AI assistant.",
3
+ "version": "0.9.0",
4
+ "description": "MCP server exposing 106 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",