@utilix-tech/mcp 0.6.0 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +10 -3
  2. package/dist/index.js +321 -0
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 101 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
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.
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 |
@@ -230,16 +231,22 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
230
231
  | `compress_json` | Compress JSON for LLM context budgets |
231
232
  | `diff_json_detailed` | Diff two parsed JSON values, returning every path with its op and unchanged paths |
232
233
 
233
- ### Image (1 tool)
234
+ ### Image (2 tools)
234
235
  | Tool | Description |
235
236
  |------|-------------|
236
237
  | `read_image_info` | Read format, dimensions, bit depth, and alpha channel from image bytes: no decoding required. Supports PNG, JPEG, GIF, WebP, BMP |
238
+ | `read_exif_data` | Read camera make/model, orientation, timestamps, exposure settings, and GPS coordinates from a JPEG's EXIF data |
237
239
 
238
240
  ### PDF (1 tool)
239
241
  | Tool | Description |
240
242
  |------|-------------|
241
243
  | `read_pdf_metadata` | Read title, author, dates, page count, and encryption flag from a PDF's trailer/Info dictionary: no PDF rendering library required |
242
244
 
245
+ ### Audio (1 tool)
246
+ | Tool | Description |
247
+ |------|-------------|
248
+ | `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 |
249
+
243
250
  ## Requirements
244
251
 
245
252
  - Node.js 18+
package/dist/index.js CHANGED
@@ -1641,6 +1641,196 @@ server.tool("read_pdf_metadata", "Read PDF metadata (title, author, dates, page
1641
1641
  if ("error" in result) return errText(result);
1642
1642
  return text(JSON.stringify(result, null, 2));
1643
1643
  });
1644
+ var _EXIF_ORIENTATION_LABELS = {
1645
+ 1: "Normal",
1646
+ 2: "Mirrored horizontal",
1647
+ 3: "Rotated 180\xB0",
1648
+ 4: "Mirrored vertical",
1649
+ 5: "Mirrored horizontal, rotated 90\xB0 CCW",
1650
+ 6: "Rotated 90\xB0 CW",
1651
+ 7: "Mirrored horizontal, rotated 90\xB0 CW",
1652
+ 8: "Rotated 90\xB0 CCW"
1653
+ };
1654
+ function _tu16(r, o) {
1655
+ return r.view.getUint16(o, r.little);
1656
+ }
1657
+ function _tu32(r, o) {
1658
+ return r.view.getUint32(o, r.little);
1659
+ }
1660
+ function _readIfdEntries(r, ifdOffset) {
1661
+ if (ifdOffset < 0 || ifdOffset + 2 > r.bytes.length) return null;
1662
+ const numEntries = _tu16(r, ifdOffset);
1663
+ const entries = [];
1664
+ for (let i = 0; i < numEntries; i++) {
1665
+ const entryOffset = ifdOffset + 2 + i * 12;
1666
+ if (entryOffset + 12 > r.bytes.length) break;
1667
+ entries.push({ tag: _tu16(r, entryOffset), type: _tu16(r, entryOffset + 2), count: _tu32(r, entryOffset + 4), valueOffsetField: entryOffset + 8 });
1668
+ }
1669
+ return { entries };
1670
+ }
1671
+ function _findExifEntry(entries, tag) {
1672
+ return entries.find((e) => e.tag === tag);
1673
+ }
1674
+ function _readExifAscii(r, entry) {
1675
+ const size = entry.count;
1676
+ const inline = size <= 4;
1677
+ const dataOffset = inline ? entry.valueOffsetField : r.tiffStart + _tu32(r, entry.valueOffsetField);
1678
+ if (dataOffset < 0 || dataOffset + size > r.bytes.length) return null;
1679
+ let out = "";
1680
+ for (let i = 0; i < size; i++) {
1681
+ const c = r.bytes[dataOffset + i];
1682
+ if (c === 0) break;
1683
+ out += String.fromCharCode(c);
1684
+ }
1685
+ const trimmed = out.trim();
1686
+ return trimmed || null;
1687
+ }
1688
+ function _readExifRational(r, entry) {
1689
+ const dataOffset = r.tiffStart + _tu32(r, entry.valueOffsetField);
1690
+ if (dataOffset < 0 || dataOffset + 8 > r.bytes.length) return null;
1691
+ return { num: _tu32(r, dataOffset), den: _tu32(r, dataOffset + 4) };
1692
+ }
1693
+ function _readExifGpsCoordinate(r, entries, valueTag, refTag) {
1694
+ const valueEntry = _findExifEntry(entries, valueTag);
1695
+ if (!valueEntry) return null;
1696
+ const dataOffset = r.tiffStart + _tu32(r, valueEntry.valueOffsetField);
1697
+ if (dataOffset < 0 || dataOffset + 24 > r.bytes.length) return null;
1698
+ const deg = _tu32(r, dataOffset) / (_tu32(r, dataOffset + 4) || 1);
1699
+ const min = _tu32(r, dataOffset + 8) / (_tu32(r, dataOffset + 12) || 1);
1700
+ const sec = _tu32(r, dataOffset + 16) / (_tu32(r, dataOffset + 20) || 1);
1701
+ let decimal = deg + min / 60 + sec / 3600;
1702
+ const refEntry = _findExifEntry(entries, refTag);
1703
+ const ref = refEntry ? _readExifAscii(r, refEntry) : null;
1704
+ if (ref === "S" || ref === "W") decimal = -decimal;
1705
+ return Math.round(decimal * 1e6) / 1e6;
1706
+ }
1707
+ function _gcdExif(a, b) {
1708
+ return b === 0 ? a : _gcdExif(b, a % b);
1709
+ }
1710
+ function _round2Exif(n) {
1711
+ return Math.round(n * 100) / 100;
1712
+ }
1713
+ function _formatExposureTime(num, den) {
1714
+ if (num === 0) return "0s";
1715
+ if (num >= den) return `${_round2Exif(num / den)}s`;
1716
+ const divisor = _gcdExif(num, den) || 1;
1717
+ const n = num / divisor;
1718
+ const d = den / divisor;
1719
+ return `1/${Math.round(d / n)}`;
1720
+ }
1721
+ function _readExifData(bytes) {
1722
+ if (bytes.length < 4 || bytes[0] !== 255 || bytes[1] !== 216) {
1723
+ return { error: "Not a JPEG file (EXIF is only read from JPEG files)" };
1724
+ }
1725
+ let offset = 2;
1726
+ let exifStart = -1;
1727
+ while (offset + 4 <= bytes.length) {
1728
+ if (bytes[offset] !== 255) {
1729
+ offset++;
1730
+ continue;
1731
+ }
1732
+ const marker = bytes[offset + 1];
1733
+ if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
1734
+ offset += 2;
1735
+ continue;
1736
+ }
1737
+ if (marker === 217 || marker === 218) break;
1738
+ const segmentLength = bytes[offset + 2] << 8 | bytes[offset + 3];
1739
+ if (marker === 225) {
1740
+ const idOffset = offset + 4;
1741
+ if (idOffset + 6 <= bytes.length && bytes[idOffset] === 69 && bytes[idOffset + 1] === 120 && bytes[idOffset + 2] === 105 && bytes[idOffset + 3] === 102 && bytes[idOffset + 4] === 0 && bytes[idOffset + 5] === 0) {
1742
+ exifStart = idOffset + 6;
1743
+ break;
1744
+ }
1745
+ }
1746
+ offset += 2 + segmentLength;
1747
+ }
1748
+ if (exifStart === -1) return { error: "No EXIF segment found in this JPEG" };
1749
+ if (exifStart + 8 > bytes.length) return { error: "Truncated EXIF segment" };
1750
+ const byteOrder = String.fromCharCode(bytes[exifStart], bytes[exifStart + 1]);
1751
+ if (byteOrder !== "II" && byteOrder !== "MM") return { error: "Invalid TIFF byte order marker in EXIF data" };
1752
+ const little = byteOrder === "II";
1753
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1754
+ const r = { bytes, view, little, tiffStart: exifStart };
1755
+ if (_tu16(r, exifStart + 2) !== 42) return { error: "Invalid TIFF magic number in EXIF data" };
1756
+ const ifd0Offset = exifStart + _tu32(r, exifStart + 4);
1757
+ const ifd0 = _readIfdEntries(r, ifd0Offset);
1758
+ if (!ifd0) return { error: "Could not read IFD0 from EXIF data" };
1759
+ const result = { tagCount: ifd0.entries.length };
1760
+ const makeEntry = _findExifEntry(ifd0.entries, 271);
1761
+ if (makeEntry) {
1762
+ const v = _readExifAscii(r, makeEntry);
1763
+ if (v) result.make = v;
1764
+ }
1765
+ const modelEntry = _findExifEntry(ifd0.entries, 272);
1766
+ if (modelEntry) {
1767
+ const v = _readExifAscii(r, modelEntry);
1768
+ if (v) result.model = v;
1769
+ }
1770
+ const softwareEntry = _findExifEntry(ifd0.entries, 305);
1771
+ if (softwareEntry) {
1772
+ const v = _readExifAscii(r, softwareEntry);
1773
+ if (v) result.software = v;
1774
+ }
1775
+ const dateTimeEntry = _findExifEntry(ifd0.entries, 306);
1776
+ if (dateTimeEntry) {
1777
+ const v = _readExifAscii(r, dateTimeEntry);
1778
+ if (v) result.dateTime = v;
1779
+ }
1780
+ const orientEntry = _findExifEntry(ifd0.entries, 274);
1781
+ if (orientEntry) {
1782
+ const v = _tu16(r, orientEntry.valueOffsetField);
1783
+ result.orientation = v;
1784
+ result.orientationLabel = _EXIF_ORIENTATION_LABELS[v] ?? "Unknown";
1785
+ }
1786
+ const exifIfdPtr = _findExifEntry(ifd0.entries, 34665);
1787
+ if (exifIfdPtr) {
1788
+ const subOffset = exifStart + _tu32(r, exifIfdPtr.valueOffsetField);
1789
+ const sub = _readIfdEntries(r, subOffset);
1790
+ if (sub) {
1791
+ const dtoEntry = _findExifEntry(sub.entries, 36867);
1792
+ if (dtoEntry) {
1793
+ const v = _readExifAscii(r, dtoEntry);
1794
+ if (v) result.dateTimeOriginal = v;
1795
+ }
1796
+ const expEntry = _findExifEntry(sub.entries, 33434);
1797
+ if (expEntry) {
1798
+ const rat = _readExifRational(r, expEntry);
1799
+ if (rat && rat.den !== 0) result.exposureTime = _formatExposureTime(rat.num, rat.den);
1800
+ }
1801
+ const fEntry = _findExifEntry(sub.entries, 33437);
1802
+ if (fEntry) {
1803
+ const rat = _readExifRational(r, fEntry);
1804
+ if (rat && rat.den !== 0) result.fNumber = _round2Exif(rat.num / rat.den);
1805
+ }
1806
+ const isoEntry = _findExifEntry(sub.entries, 34855);
1807
+ if (isoEntry) result.isoSpeed = _tu16(r, isoEntry.valueOffsetField);
1808
+ const focalEntry = _findExifEntry(sub.entries, 37386);
1809
+ if (focalEntry) {
1810
+ const rat = _readExifRational(r, focalEntry);
1811
+ if (rat && rat.den !== 0) result.focalLength = _round2Exif(rat.num / rat.den);
1812
+ }
1813
+ }
1814
+ }
1815
+ const gpsIfdPtr = _findExifEntry(ifd0.entries, 34853);
1816
+ if (gpsIfdPtr) {
1817
+ const gpsOffset = exifStart + _tu32(r, gpsIfdPtr.valueOffsetField);
1818
+ const gps = _readIfdEntries(r, gpsOffset);
1819
+ if (gps) {
1820
+ const lat = _readExifGpsCoordinate(r, gps.entries, 2, 1);
1821
+ const lon = _readExifGpsCoordinate(r, gps.entries, 4, 3);
1822
+ if (lat !== null) result.gpsLatitude = lat;
1823
+ if (lon !== null) result.gpsLongitude = lon;
1824
+ }
1825
+ }
1826
+ return result;
1827
+ }
1828
+ server.tool("read_exif_data", "Read common EXIF tags (camera make/model, orientation, timestamps, exposure settings, GPS coordinates) directly from a JPEG's APP1 segment. Only JPEG is supported.", { image_base64: z.string().describe("Base64-encoded JPEG bytes, optionally prefixed with a data: URL scheme") }, async ({ image_base64 }) => {
1829
+ const bytes = new Uint8Array(Buffer.from(image_base64.replace(/^data:.*;base64,/, ""), "base64"));
1830
+ const result = _readExifData(bytes);
1831
+ if ("error" in result) return errText(result);
1832
+ return text(JSON.stringify(result, null, 2));
1833
+ });
1644
1834
  function _parseJwks(input) {
1645
1835
  const trimmed = input.trim();
1646
1836
  if (!trimmed) return { error: "No input provided" };
@@ -1686,5 +1876,136 @@ server.tool("parse_jwks", "Parse a JSON Web Key Set (or a single bare JWK) and s
1686
1876
  if ("error" in result) return errText(result);
1687
1877
  return text(JSON.stringify(result, null, 2));
1688
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
+ });
1689
2010
  var transport = new StdioServerTransport();
1690
2011
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.6.0",
4
- "description": "MCP server exposing 101 Utilix developer tools to Claude, Cursor, VS Code Copilot, and any MCP-compatible AI assistant.",
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.",
5
5
  "author": "Utilix <hello@utilix.tech>",
6
6
  "license": "MIT",
7
7
  "homepage": "https://utilix.tech/mcp",