@utilix-tech/mcp 0.28.0 → 0.30.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,13 @@
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.30.0
6
+ - Add `read_pdf_page_dimensions` tool: each page's MediaBox width/height (points and inches), rotation, and orientation, walking the Root -> Pages -> Kids tree and inheriting from ancestor nodes, plus a common paper size match (Letter, A4, etc.).
7
+ - Add `parse_ipv6_address` tool: expand an IPv6 address to its full 8-group form or compress it to its RFC 5952 canonical shortest form, classify its address type, and handle an embedded IPv4 suffix or zone ID.
8
+
9
+ ## 0.29.0
10
+ - Add `read_video_info` tool: duration, resolution, and video/audio codec identifiers from an MP4 (ISO BMFF moov box) or WebM (EBML/Matroska Segment) container header, with no frame decoding or transcoding.
11
+
5
12
  ## 0.28.0
6
13
  - Add `calculate_rule_of_72` tool: estimate years to double an investment from a rate, or the rate needed to double in a given number of years, alongside the exact logarithmic result.
7
14
  - Add `read_flac_info` tool: sample rate, channels, bit depth, total samples, and duration from a FLAC file's STREAMINFO block, plus Vorbis comment tags when present.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 143 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
3
+ MCP server exposing 146 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
 
@@ -177,7 +177,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
177
177
  | `calculate_video_bitrate` | Calculate a video's file size from a bitrate and duration, or the bitrate needed to hit a target file size |
178
178
  | `calculate_rule_of_72` | Estimate years to double an investment from a rate, or the rate needed to double in a given number of years, via the Rule of 72 shortcut and the exact logarithmic result |
179
179
 
180
- ### API / Network (11 tools)
180
+ ### API / Network (12 tools)
181
181
  | Tool | Description |
182
182
  |------|-------------|
183
183
  | `decode_jwt` | Decode a JWT: header, payload, and expiry |
@@ -191,6 +191,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
191
191
  | `validate_robots_txt` | Validate a robots.txt file: User-agent groups, rules, and directive syntax |
192
192
  | `parse_user_agent` | Parse a browser User-Agent string into browser, engine, OS, and device type |
193
193
  | `build_cache_control` | Build and explain an HTTP Cache-Control response header from directives (max-age, no-store, immutable, etc.) |
194
+ | `parse_ipv6_address` | Expand an IPv6 address to its full 8-group form or compress it to its RFC 5952 canonical shortest form, classify its address type, and handle an embedded IPv4 suffix or zone ID |
194
195
 
195
196
  ### Color (4 tools)
196
197
  | Tool | Description |
@@ -274,10 +275,11 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
274
275
  | `read_exif_data` | Read camera make/model, orientation, timestamps, exposure settings, and GPS coordinates from a JPEG's EXIF data |
275
276
  | `read_ico_info` | Read how many images a .ico file embeds and each one's width, height, color depth, and size from its ICONDIR header |
276
277
 
277
- ### PDF (1 tool)
278
+ ### PDF (2 tools)
278
279
  | Tool | Description |
279
280
  |------|-------------|
280
281
  | `read_pdf_metadata` | Read title, author, dates, page count, and encryption flag from a PDF's trailer/Info dictionary: no PDF rendering library required |
282
+ | `read_pdf_page_dimensions` | List each page's MediaBox width/height (points and inches), rotation, and orientation, walking the Root -> Pages -> Kids tree: no PDF rendering library required |
281
283
 
282
284
  ### Audio (3 tools)
283
285
  | Tool | Description |
@@ -286,6 +288,11 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
286
288
  | `read_id3_tags` | Read title, artist, album, year, genre, comment, and track number from an MP3's ID3 tags: no audio library required |
287
289
  | `read_flac_info` | Read sample rate, channels, bit depth, total samples, and duration from a FLAC file's STREAMINFO block, plus Vorbis comment tags if present: no audio library required |
288
290
 
291
+ ### Video (1 tool)
292
+ | Tool | Description |
293
+ |------|-------------|
294
+ | `read_video_info` | Read duration, resolution, and video/audio codec identifiers from an MP4 (ISO BMFF) or WebM (EBML/Matroska) file's container header: no frame decoding, demuxing, or transcoding |
295
+
289
296
  ## Requirements
290
297
 
291
298
  - Node.js 18+
package/dist/index.js CHANGED
@@ -2592,6 +2592,131 @@ server.tool("read_pdf_metadata", "Read PDF metadata (title, author, dates, page
2592
2592
  if ("error" in result) return errText(result);
2593
2593
  return text(JSON.stringify(result, null, 2));
2594
2594
  });
2595
+ var _PDF_COMMON_PAPER_SIZES = [
2596
+ { name: "Letter", widthPt: 612, heightPt: 792 },
2597
+ { name: "Legal", widthPt: 612, heightPt: 1008 },
2598
+ { name: "Tabloid", widthPt: 792, heightPt: 1224 },
2599
+ { name: "A3", widthPt: 842, heightPt: 1191 },
2600
+ { name: "A4", widthPt: 595, heightPt: 842 },
2601
+ { name: "A5", widthPt: 420, heightPt: 595 },
2602
+ { name: "B5", widthPt: 499, heightPt: 709 }
2603
+ ];
2604
+ function _matchPdfPaperSize(widthPt, heightPt) {
2605
+ for (const size of _PDF_COMMON_PAPER_SIZES) {
2606
+ const w = Math.abs(widthPt - size.widthPt) <= 1;
2607
+ const h = Math.abs(heightPt - size.heightPt) <= 1;
2608
+ const wSwapped = Math.abs(widthPt - size.heightPt) <= 1;
2609
+ const hSwapped = Math.abs(heightPt - size.widthPt) <= 1;
2610
+ if (w && h || wSwapped && hSwapped) return size.name;
2611
+ }
2612
+ return null;
2613
+ }
2614
+ function _parsePdfMediaBox(dict) {
2615
+ const m = dict.match(/\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\]/);
2616
+ if (!m) return null;
2617
+ return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
2618
+ }
2619
+ function _parsePdfRotate(dict) {
2620
+ const m = dict.match(/\/Rotate\s+(-?\d+)/);
2621
+ return m ? Number(m[1]) : null;
2622
+ }
2623
+ function _round2Pdf(n) {
2624
+ return Math.round(n * 100) / 100;
2625
+ }
2626
+ function _buildPdfPageDimension(pageNumber, mediaBox, rotate) {
2627
+ const rotation = (rotate % 360 + 360) % 360;
2628
+ if (!mediaBox) {
2629
+ return { pageNumber, widthPt: null, heightPt: null, widthIn: null, heightIn: null, rotation, orientation: null, paperSize: null };
2630
+ }
2631
+ const [llx, lly, urx, ury] = mediaBox;
2632
+ let widthPt = Math.abs(urx - llx);
2633
+ let heightPt = Math.abs(ury - lly);
2634
+ if (rotation === 90 || rotation === 270) [widthPt, heightPt] = [heightPt, widthPt];
2635
+ const orientation = widthPt === heightPt ? "square" : widthPt > heightPt ? "landscape" : "portrait";
2636
+ return {
2637
+ pageNumber,
2638
+ widthPt: _round2Pdf(widthPt),
2639
+ heightPt: _round2Pdf(heightPt),
2640
+ widthIn: _round2Pdf(widthPt / 72),
2641
+ heightIn: _round2Pdf(heightPt / 72),
2642
+ rotation,
2643
+ orientation,
2644
+ paperSize: _matchPdfPaperSize(widthPt, heightPt)
2645
+ };
2646
+ }
2647
+ function _walkPdfPageTree(objMap, nodeBody, inherited, visited, nodeKey, out) {
2648
+ if (visited.has(nodeKey)) return;
2649
+ visited.add(nodeKey);
2650
+ const mediaBox = _parsePdfMediaBox(nodeBody) ?? inherited.mediaBox;
2651
+ const rotateMatch = _parsePdfRotate(nodeBody);
2652
+ const rotate = rotateMatch !== null ? rotateMatch : inherited.rotate;
2653
+ if (/\/Type\s*\/Page(?!s)\b/.test(nodeBody)) {
2654
+ out.push(_buildPdfPageDimension(out.length + 1, mediaBox, rotate));
2655
+ return;
2656
+ }
2657
+ const kidsMatch = nodeBody.match(/\/Kids\s*\[([^\]]*)\]/);
2658
+ if (!kidsMatch) return;
2659
+ const kidRefs = [...kidsMatch[1].matchAll(/(\d+)\s+(\d+)\s+R/g)];
2660
+ for (const ref of kidRefs) {
2661
+ const key = `${ref[1]} ${ref[2]}`;
2662
+ const body = objMap.get(key);
2663
+ if (body) _walkPdfPageTree(objMap, body, { mediaBox, rotate }, visited, key, out);
2664
+ }
2665
+ }
2666
+ function _readPdfPageDimensions(bytes) {
2667
+ if (bytes.length < 8) return { error: "File too small to be a PDF" };
2668
+ const latin1 = (b) => {
2669
+ let out2 = "";
2670
+ const CHUNK = 32768;
2671
+ for (let i = 0; i < b.length; i += CHUNK) out2 += String.fromCharCode(...b.subarray(i, i + CHUNK));
2672
+ return out2;
2673
+ };
2674
+ const header = latin1(bytes.subarray(0, 16));
2675
+ const versionMatch = header.match(/%PDF-(\d\.\d)/);
2676
+ if (!versionMatch) return { error: "Not a valid PDF file (missing %PDF header)" };
2677
+ const version = versionMatch[1];
2678
+ const text2 = latin1(bytes);
2679
+ const objBlocks = [...text2.matchAll(/(\d+)\s+(\d+)\s+obj([\s\S]*?)endobj/g)];
2680
+ const objMap = /* @__PURE__ */ new Map();
2681
+ for (const b of objBlocks) objMap.set(`${b[1]} ${b[2]}`, b[3]);
2682
+ const out = [];
2683
+ const visited = /* @__PURE__ */ new Set();
2684
+ let pagesRootBody = null;
2685
+ const trailerMatches = [...text2.matchAll(/trailer\s*<<([\s\S]*?)>>/g)];
2686
+ if (trailerMatches.length > 0) {
2687
+ const trailer = trailerMatches[trailerMatches.length - 1][1];
2688
+ const rootRef = trailer.match(/\/Root\s+(\d+)\s+(\d+)\s+R/);
2689
+ if (rootRef) {
2690
+ const catalogBody = objMap.get(`${rootRef[1]} ${rootRef[2]}`);
2691
+ const pagesRef = catalogBody?.match(/\/Pages\s+(\d+)\s+(\d+)\s+R/);
2692
+ if (pagesRef) pagesRootBody = objMap.get(`${pagesRef[1]} ${pagesRef[2]}`) ?? null;
2693
+ }
2694
+ }
2695
+ if (!pagesRootBody) {
2696
+ for (const b of objBlocks) {
2697
+ if (/\/Type\s*\/Pages\b/.test(b[3]) && !/\/Parent\s+\d+\s+\d+\s+R/.test(b[3])) {
2698
+ pagesRootBody = b[3];
2699
+ break;
2700
+ }
2701
+ }
2702
+ }
2703
+ if (pagesRootBody) _walkPdfPageTree(objMap, pagesRootBody, { mediaBox: null, rotate: 0 }, visited, "root", out);
2704
+ if (out.length === 0) {
2705
+ for (const b of objBlocks) {
2706
+ if (/\/Type\s*\/Page(?!s)\b/.test(b[3])) {
2707
+ out.push(_buildPdfPageDimension(out.length + 1, _parsePdfMediaBox(b[3]), _parsePdfRotate(b[3]) ?? 0));
2708
+ }
2709
+ }
2710
+ }
2711
+ if (out.length === 0) return { error: "Could not locate any page objects in this PDF" };
2712
+ return { version, pageCount: out.length, pages: out };
2713
+ }
2714
+ server.tool("read_pdf_page_dimensions", "List each page's MediaBox width/height (points and inches), rotation, and orientation, in document order, by walking the Root -> Pages -> Kids tree: no PDF rendering library required.", { pdf_base64: z.string().describe("Base64-encoded PDF bytes, optionally prefixed with a data: URL scheme") }, async ({ pdf_base64 }) => {
2715
+ const bytes = new Uint8Array(Buffer.from(pdf_base64.replace(/^data:.*;base64,/, ""), "base64"));
2716
+ const result = _readPdfPageDimensions(bytes);
2717
+ if ("error" in result) return errText(result);
2718
+ return text(JSON.stringify(result, null, 2));
2719
+ });
2595
2720
  var _EXIF_ORIENTATION_LABELS = {
2596
2721
  1: "Normal",
2597
2722
  2: "Mirrored horizontal",
@@ -3940,6 +4065,99 @@ server.tool(
3940
4065
  return text(JSON.stringify(result, null, 2));
3941
4066
  }
3942
4067
  );
4068
+ function _isValidIpv6HexGroup(g) {
4069
+ return /^[0-9a-fA-F]{1,4}$/.test(g);
4070
+ }
4071
+ function _isValidIpv6Ipv4Quad(quad) {
4072
+ const parts = quad.split(".");
4073
+ if (parts.length !== 4) return false;
4074
+ return parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
4075
+ }
4076
+ function _classifyIpv6(groups) {
4077
+ const first = parseInt(groups[0], 16);
4078
+ if (groups.every((g) => g === "0000")) return "unspecified";
4079
+ if (groups.slice(0, 7).every((g) => g === "0000") && groups[7] === "0001") return "loopback";
4080
+ if (groups.slice(0, 4).every((g) => g === "0000") && groups[4] === "0000" && groups[5] === "ffff") return "ipv4-mapped";
4081
+ if (groups[0] === "2001" && groups[1] === "0db8") return "documentation";
4082
+ if ((first & 65280) === 65280) return "multicast";
4083
+ if ((first & 65472) === 65152) return "link-local";
4084
+ if ((first & 65024) === 64512) return "unique-local";
4085
+ return "global-unicast";
4086
+ }
4087
+ function _compressIpv6Groups(groups) {
4088
+ const short = groups.map((g) => g.replace(/^0+(?=.)/, ""));
4089
+ let bestStart = -1;
4090
+ let bestLen = 0;
4091
+ let curStart = -1;
4092
+ let curLen = 0;
4093
+ for (let i = 0; i < short.length; i++) {
4094
+ if (short[i] === "0") {
4095
+ if (curStart === -1) curStart = i;
4096
+ curLen++;
4097
+ if (curLen > bestLen) {
4098
+ bestLen = curLen;
4099
+ bestStart = curStart;
4100
+ }
4101
+ } else {
4102
+ curStart = -1;
4103
+ curLen = 0;
4104
+ }
4105
+ }
4106
+ if (bestLen < 2) return short.join(":");
4107
+ const before = short.slice(0, bestStart).join(":");
4108
+ const after = short.slice(bestStart + bestLen).join(":");
4109
+ return `${before}::${after}`;
4110
+ }
4111
+ function _parseIpv6Address(input) {
4112
+ let s = input.trim();
4113
+ if (s.startsWith("[") && s.includes("]")) s = s.slice(1, s.indexOf("]"));
4114
+ let zoneId = null;
4115
+ const pctIdx = s.indexOf("%");
4116
+ if (pctIdx !== -1) {
4117
+ zoneId = s.slice(pctIdx + 1);
4118
+ s = s.slice(0, pctIdx);
4119
+ }
4120
+ if (!s) return { error: "Empty address" };
4121
+ const doubleColonCount = (s.match(/::/g) ?? []).length;
4122
+ if (doubleColonCount > 1) return { error: 'Address contains more than one "::"' };
4123
+ const ipv4Match = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
4124
+ if (ipv4Match) {
4125
+ const quad = ipv4Match[1];
4126
+ if (!_isValidIpv6Ipv4Quad(quad)) return { error: `Invalid embedded IPv4 address: ${quad}` };
4127
+ const parts = quad.split(".").map(Number);
4128
+ const hi = (parts[0] << 8 | parts[1]).toString(16);
4129
+ const lo = (parts[2] << 8 | parts[3]).toString(16);
4130
+ s = s.slice(0, s.length - quad.length) + `${hi}:${lo}`;
4131
+ }
4132
+ let groups;
4133
+ if (s.includes("::")) {
4134
+ const [left, right] = s.split("::");
4135
+ const leftGroups = left ? left.split(":") : [];
4136
+ const rightGroups = right ? right.split(":") : [];
4137
+ const missing = 8 - (leftGroups.length + rightGroups.length);
4138
+ if (missing < 1) return { error: 'The "::" shorthand must represent at least one group of zeros' };
4139
+ if (![...leftGroups, ...rightGroups].every(_isValidIpv6HexGroup)) return { error: "Invalid hex group in address" };
4140
+ groups = [...leftGroups, ...Array(missing).fill("0"), ...rightGroups];
4141
+ } else {
4142
+ groups = s.split(":");
4143
+ if (groups.length !== 8) return { error: `Expected 8 groups, got ${groups.length}` };
4144
+ if (!groups.every(_isValidIpv6HexGroup)) return { error: "Invalid hex group in address" };
4145
+ }
4146
+ const expandedGroups = groups.map((g) => g.padStart(4, "0").toLowerCase());
4147
+ const suffix = zoneId ? `%${zoneId}` : "";
4148
+ return {
4149
+ expanded: expandedGroups.join(":") + suffix,
4150
+ compressed: _compressIpv6Groups(expandedGroups) + suffix,
4151
+ groups: expandedGroups,
4152
+ zoneId,
4153
+ addressType: _classifyIpv6(expandedGroups)
4154
+ };
4155
+ }
4156
+ server.tool("parse_ipv6_address", 'Expand an IPv6 address to its full 8-group form and compress it to its RFC 5952 canonical shortest form, classify its address type (loopback, link-local, unique-local, multicast, IPv4-mapped, documentation, or global unicast), and separate out an optional zone ID. Understands an embedded IPv4 suffix like "::ffff:192.168.1.1".', { address: z.string().describe('The IPv6 address to expand/compress, e.g. "2001:db8::ff00:42:8329"') }, async ({ address }) => {
4157
+ const result = _parseIpv6Address(address);
4158
+ if ("error" in result) return errText(result);
4159
+ return text(JSON.stringify(result, null, 2));
4160
+ });
3943
4161
  var _FILLER_WORDS_MCP = ["very", "really", "just", "actually", "basically", "literally", "simply", "totally", "definitely", "certainly", "essentially", "absolutely", "completely", "extremely", "truly", "honestly", "seriously", "obviously", "clearly", "quite", "rather", "somewhat", "fairly", "virtually"];
3944
4162
  var _HEDGE_PHRASES_MCP = ["maybe", "perhaps", "probably", "possibly", "arguably", "kind of", "sort of", "a bit", "a little", "more or less", "in my opinion", "i think", "i believe", "to some extent"];
3945
4163
  var _CLICHE_PHRASES_MCP = ["at the end of the day", "think outside the box", "low-hanging fruit", "move the needle", "circle back", "touch base", "on the same page", "hit the ground running", "paradigm shift", "win-win", "game changer", "best of both worlds", "only time will tell", "back to the drawing board", "the tip of the iceberg", "a perfect storm", "needle in a haystack", "raise the bar", "push the envelope", "the elephant in the room", "boil the ocean", "when it rains it pours"];
@@ -4699,5 +4917,265 @@ server.tool(
4699
4917
  return text(JSON.stringify(result, null, 2));
4700
4918
  }
4701
4919
  );
4920
+ function _videoReadU32BE(bytes, offset) {
4921
+ return (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
4922
+ }
4923
+ function _videoReadU64BE(bytes, offset) {
4924
+ return _videoReadU32BE(bytes, offset) * 4294967296 + _videoReadU32BE(bytes, offset + 4);
4925
+ }
4926
+ function _videoReadAscii4(bytes, offset) {
4927
+ return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
4928
+ }
4929
+ function _readMp4Boxes(bytes, start, end) {
4930
+ const boxes = [];
4931
+ let offset = start;
4932
+ while (offset + 8 <= end) {
4933
+ let size = _videoReadU32BE(bytes, offset);
4934
+ const type = _videoReadAscii4(bytes, offset + 4);
4935
+ let headerSize = 8;
4936
+ if (size === 1) {
4937
+ if (offset + 16 > end) break;
4938
+ size = _videoReadU64BE(bytes, offset + 8);
4939
+ headerSize = 16;
4940
+ } else if (size === 0) {
4941
+ size = end - offset;
4942
+ }
4943
+ if (size < headerSize || offset + size > end) break;
4944
+ boxes.push({ type, start: offset + headerSize, end: offset + size });
4945
+ offset += size;
4946
+ }
4947
+ return boxes;
4948
+ }
4949
+ function _findMp4Box(boxes, type) {
4950
+ return boxes.find((b) => b.type === type);
4951
+ }
4952
+ function _parseMp4Mvhd(bytes, box) {
4953
+ const o = box.start;
4954
+ if (o >= box.end) return null;
4955
+ const version = bytes[o];
4956
+ if (version === 1) {
4957
+ if (o + 32 > box.end) return null;
4958
+ return { timescale: _videoReadU32BE(bytes, o + 20), duration: _videoReadU64BE(bytes, o + 24) };
4959
+ }
4960
+ if (o + 20 > box.end) return null;
4961
+ return { timescale: _videoReadU32BE(bytes, o + 12), duration: _videoReadU32BE(bytes, o + 16) };
4962
+ }
4963
+ function _parseMp4Tkhd(bytes, box) {
4964
+ const o = box.start;
4965
+ if (o >= box.end) return null;
4966
+ const version = bytes[o];
4967
+ const fixedFieldsSize = version === 1 ? 4 + 8 + 8 + 4 + 4 + 8 : 4 + 4 + 4 + 4 + 4 + 4;
4968
+ const afterDuration = o + fixedFieldsSize;
4969
+ const matrixStart = afterDuration + 8 + 2 + 2 + 2 + 2;
4970
+ const widthOffset = matrixStart + 36;
4971
+ if (widthOffset + 8 > box.end) return null;
4972
+ return { width: _videoReadU32BE(bytes, widthOffset) / 65536, height: _videoReadU32BE(bytes, widthOffset + 4) / 65536 };
4973
+ }
4974
+ function _parseMp4Hdlr(bytes, box) {
4975
+ const o = box.start;
4976
+ if (o + 12 > box.end) return null;
4977
+ return _videoReadAscii4(bytes, o + 8);
4978
+ }
4979
+ function _parseMp4StsdCodec(bytes, box) {
4980
+ const o = box.start;
4981
+ if (o + 8 > box.end) return null;
4982
+ const entryCount = _videoReadU32BE(bytes, o + 4);
4983
+ if (entryCount < 1) return null;
4984
+ const entryStart = o + 8;
4985
+ if (entryStart + 8 > box.end) return null;
4986
+ return _videoReadAscii4(bytes, entryStart + 4);
4987
+ }
4988
+ function _parseMp4StsdVideoDims(bytes, box) {
4989
+ const o = box.start;
4990
+ if (o + 8 > box.end) return null;
4991
+ const entryCount = _videoReadU32BE(bytes, o + 4);
4992
+ if (entryCount < 1) return null;
4993
+ const entryStart = o + 8;
4994
+ if (entryStart + 36 > box.end) return null;
4995
+ return {
4996
+ width: bytes[entryStart + 32] << 8 | bytes[entryStart + 33],
4997
+ height: bytes[entryStart + 34] << 8 | bytes[entryStart + 35]
4998
+ };
4999
+ }
5000
+ function _parseMp4Video(bytes) {
5001
+ const topBoxes = _readMp4Boxes(bytes, 0, bytes.length);
5002
+ const ftyp = _findMp4Box(topBoxes, "ftyp");
5003
+ const moov = _findMp4Box(topBoxes, "moov");
5004
+ if (!moov) return { error: "No moov box found (not a valid or fully-downloaded MP4 file)" };
5005
+ const result = { format: "mp4" };
5006
+ if (ftyp && ftyp.start + 4 <= ftyp.end) result.majorBrand = _videoReadAscii4(bytes, ftyp.start);
5007
+ const moovBoxes = _readMp4Boxes(bytes, moov.start, moov.end);
5008
+ const mvhd = _findMp4Box(moovBoxes, "mvhd");
5009
+ if (mvhd) {
5010
+ const info = _parseMp4Mvhd(bytes, mvhd);
5011
+ if (info && info.timescale > 0) result.durationSeconds = Math.round(info.duration / info.timescale * 1e3) / 1e3;
5012
+ }
5013
+ for (const trak of moovBoxes.filter((b) => b.type === "trak")) {
5014
+ const trakBoxes = _readMp4Boxes(bytes, trak.start, trak.end);
5015
+ const mdia = _findMp4Box(trakBoxes, "mdia");
5016
+ if (!mdia) continue;
5017
+ const mdiaBoxes = _readMp4Boxes(bytes, mdia.start, mdia.end);
5018
+ const hdlr = _findMp4Box(mdiaBoxes, "hdlr");
5019
+ const handlerType = hdlr ? _parseMp4Hdlr(bytes, hdlr) : null;
5020
+ const minf = _findMp4Box(mdiaBoxes, "minf");
5021
+ if (!minf) continue;
5022
+ const stbl = _findMp4Box(_readMp4Boxes(bytes, minf.start, minf.end), "stbl");
5023
+ if (!stbl) continue;
5024
+ const stsd = _findMp4Box(_readMp4Boxes(bytes, stbl.start, stbl.end), "stsd");
5025
+ const codec = stsd ? _parseMp4StsdCodec(bytes, stsd) : null;
5026
+ if (handlerType === "vide") {
5027
+ if (codec) result.videoCodec = codec;
5028
+ const tkhd = _findMp4Box(trakBoxes, "tkhd");
5029
+ const dims = tkhd ? _parseMp4Tkhd(bytes, tkhd) : null;
5030
+ if (dims && dims.width > 0 && dims.height > 0) {
5031
+ result.width = Math.round(dims.width);
5032
+ result.height = Math.round(dims.height);
5033
+ } else if (stsd) {
5034
+ const pixelDims = _parseMp4StsdVideoDims(bytes, stsd);
5035
+ if (pixelDims && pixelDims.width > 0 && pixelDims.height > 0) {
5036
+ result.width = pixelDims.width;
5037
+ result.height = pixelDims.height;
5038
+ }
5039
+ }
5040
+ } else if (handlerType === "soun") {
5041
+ if (codec) result.audioCodec = codec;
5042
+ }
5043
+ }
5044
+ if (result.durationSeconds === void 0 && !result.videoCodec && !result.audioCodec) {
5045
+ return { error: "Could not find any usable track metadata in this MP4 file's moov box" };
5046
+ }
5047
+ return result;
5048
+ }
5049
+ function _ebmlVintLength(firstByte) {
5050
+ if (firstByte === 0) return 0;
5051
+ let mask = 128;
5052
+ let length = 1;
5053
+ while (length <= 8 && (firstByte & mask) === 0) {
5054
+ mask >>= 1;
5055
+ length++;
5056
+ }
5057
+ return length;
5058
+ }
5059
+ function _readEbmlId(bytes, offset) {
5060
+ if (offset >= bytes.length) return null;
5061
+ const length = _ebmlVintLength(bytes[offset]);
5062
+ if (length === 0 || offset + length > bytes.length) return null;
5063
+ let id = 0;
5064
+ for (let i = 0; i < length; i++) id = id * 256 + bytes[offset + i];
5065
+ return { id, length };
5066
+ }
5067
+ function _readEbmlSize(bytes, offset) {
5068
+ if (offset >= bytes.length) return null;
5069
+ const length = _ebmlVintLength(bytes[offset]);
5070
+ if (length === 0 || offset + length > bytes.length) return null;
5071
+ const mask = 255 >> length;
5072
+ let value = bytes[offset] & mask;
5073
+ let allOnes = value === mask;
5074
+ for (let i = 1; i < length; i++) {
5075
+ value = value * 256 + bytes[offset + i];
5076
+ if (bytes[offset + i] !== 255) allOnes = false;
5077
+ }
5078
+ return { value, length, unknown: allOnes };
5079
+ }
5080
+ function _readEbmlUint(bytes, offset, length) {
5081
+ let value = 0;
5082
+ for (let i = 0; i < length; i++) value = value * 256 + bytes[offset + i];
5083
+ return value;
5084
+ }
5085
+ function _readEbmlFloat(bytes, offset, length) {
5086
+ if (offset + length > bytes.length) return 0;
5087
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, length);
5088
+ if (length === 4) return view.getFloat32(0, false);
5089
+ if (length === 8) return view.getFloat64(0, false);
5090
+ return 0;
5091
+ }
5092
+ function _ebmlChildren(bytes, start, end) {
5093
+ const children = [];
5094
+ let offset = start;
5095
+ while (offset < end) {
5096
+ const idInfo = _readEbmlId(bytes, offset);
5097
+ if (!idInfo) break;
5098
+ const sizeInfo = _readEbmlSize(bytes, offset + idInfo.length);
5099
+ if (!sizeInfo) break;
5100
+ const dataStart = offset + idInfo.length + sizeInfo.length;
5101
+ const dataEnd = sizeInfo.unknown ? end : Math.min(dataStart + sizeInfo.value, end);
5102
+ children.push({ id: idInfo.id, dataStart, dataEnd });
5103
+ offset = dataEnd;
5104
+ }
5105
+ return children;
5106
+ }
5107
+ var _EBML_ID_SEGMENT = 408125543;
5108
+ var _EBML_ID_INFO = 357149030;
5109
+ var _EBML_ID_TIMECODE_SCALE = 2807729;
5110
+ var _EBML_ID_DURATION = 17545;
5111
+ var _EBML_ID_TRACKS = 374648427;
5112
+ var _EBML_ID_TRACK_ENTRY = 174;
5113
+ var _EBML_ID_TRACK_TYPE = 131;
5114
+ var _EBML_ID_CODEC_ID = 134;
5115
+ var _EBML_ID_VIDEO = 224;
5116
+ var _EBML_ID_PIXEL_WIDTH = 176;
5117
+ var _EBML_ID_PIXEL_HEIGHT = 186;
5118
+ var _EBML_ID_CLUSTER = 524531317;
5119
+ function _parseWebmVideo(bytes) {
5120
+ const top = _ebmlChildren(bytes, 0, bytes.length);
5121
+ const segment = top.find((c) => c.id === _EBML_ID_SEGMENT);
5122
+ if (!segment) return { error: "No Segment element found (not a valid WebM/Matroska file)" };
5123
+ const result = { format: "webm" };
5124
+ let timecodeScale = 1e6;
5125
+ let rawDuration;
5126
+ for (const child of _ebmlChildren(bytes, segment.dataStart, segment.dataEnd)) {
5127
+ if (child.id === _EBML_ID_CLUSTER) break;
5128
+ if (child.id === _EBML_ID_INFO) {
5129
+ for (const f of _ebmlChildren(bytes, child.dataStart, child.dataEnd)) {
5130
+ if (f.id === _EBML_ID_TIMECODE_SCALE) timecodeScale = _readEbmlUint(bytes, f.dataStart, f.dataEnd - f.dataStart) || 1e6;
5131
+ else if (f.id === _EBML_ID_DURATION) rawDuration = _readEbmlFloat(bytes, f.dataStart, f.dataEnd - f.dataStart);
5132
+ }
5133
+ } else if (child.id === _EBML_ID_TRACKS) {
5134
+ for (const entry of _ebmlChildren(bytes, child.dataStart, child.dataEnd)) {
5135
+ if (entry.id !== _EBML_ID_TRACK_ENTRY) continue;
5136
+ let trackType;
5137
+ let codecId;
5138
+ let pixelWidth;
5139
+ let pixelHeight;
5140
+ for (const f of _ebmlChildren(bytes, entry.dataStart, entry.dataEnd)) {
5141
+ if (f.id === _EBML_ID_TRACK_TYPE) trackType = _readEbmlUint(bytes, f.dataStart, f.dataEnd - f.dataStart);
5142
+ else if (f.id === _EBML_ID_CODEC_ID) codecId = Buffer.from(bytes.subarray(f.dataStart, f.dataEnd)).toString("ascii").replace(/\0+$/, "");
5143
+ else if (f.id === _EBML_ID_VIDEO) {
5144
+ for (const v of _ebmlChildren(bytes, f.dataStart, f.dataEnd)) {
5145
+ if (v.id === _EBML_ID_PIXEL_WIDTH) pixelWidth = _readEbmlUint(bytes, v.dataStart, v.dataEnd - v.dataStart);
5146
+ else if (v.id === _EBML_ID_PIXEL_HEIGHT) pixelHeight = _readEbmlUint(bytes, v.dataStart, v.dataEnd - v.dataStart);
5147
+ }
5148
+ }
5149
+ }
5150
+ if (trackType === 1) {
5151
+ if (codecId) result.videoCodec = codecId;
5152
+ if (pixelWidth) result.width = pixelWidth;
5153
+ if (pixelHeight) result.height = pixelHeight;
5154
+ } else if (trackType === 2) {
5155
+ if (codecId) result.audioCodec = codecId;
5156
+ }
5157
+ }
5158
+ }
5159
+ }
5160
+ if (rawDuration !== void 0 && timecodeScale > 0) {
5161
+ result.durationSeconds = Math.round(rawDuration * timecodeScale / 1e9 * 1e3) / 1e3;
5162
+ }
5163
+ if (result.durationSeconds === void 0 && !result.videoCodec && !result.audioCodec) {
5164
+ return { error: "Could not find any usable metadata in this WebM file's Segment element" };
5165
+ }
5166
+ return result;
5167
+ }
5168
+ function _readVideoInfo(bytes) {
5169
+ if (bytes.length < 12) return { error: "File is too small to be a valid video file" };
5170
+ if (_videoReadAscii4(bytes, 4) === "ftyp") return _parseMp4Video(bytes);
5171
+ if (bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163) return _parseWebmVideo(bytes);
5172
+ return { error: 'Not a recognized MP4 or WebM file (missing an "ftyp" box or an EBML header signature)' };
5173
+ }
5174
+ server.tool("read_video_info", "Read duration, resolution, and video/audio codec identifiers from an MP4 (ISO BMFF) or WebM (EBML/Matroska) file's container header. No frame decoding, demuxing, or transcoding is performed.", { video_base64: z.string().describe("Base64-encoded MP4 or WebM file bytes, optionally prefixed with a data: URL scheme") }, async ({ video_base64 }) => {
5175
+ const bytes = new Uint8Array(Buffer.from(video_base64.replace(/^data:.*;base64,/, ""), "base64"));
5176
+ const result = _readVideoInfo(bytes);
5177
+ if ("error" in result) return errText(result);
5178
+ return text(JSON.stringify(result, null, 2));
5179
+ });
4702
5180
  var transport = new StdioServerTransport();
4703
5181
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "MCP server exposing 143 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",