@utilix-tech/mcp 0.27.0 → 0.29.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 +7 -0
- package/README.md +10 -3
- package/dist/index.js +403 -0
- package/package.json +2 -2
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.29.0
|
|
6
|
+
- 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.
|
|
7
|
+
|
|
8
|
+
## 0.28.0
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
5
12
|
## 0.27.0
|
|
6
13
|
- Add `compute_jwk_thumbprint` tool: RFC 7638 canonical thumbprint of a single JWK, hashed with SHA-256/384/512.
|
|
7
14
|
- Add `calculate_video_bitrate` tool: resulting file size from a video/audio bitrate and duration, or the required bitrate to hit a target file size.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @utilix-tech/mcp
|
|
2
2
|
|
|
3
|
-
MCP server exposing
|
|
3
|
+
MCP server exposing 144 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
|
|
|
@@ -153,7 +153,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
153
153
|
| `convert_timezone` | Convert a date/time between timezones |
|
|
154
154
|
| `convert_iso8601_duration` | Parse an ISO 8601 duration into components and total seconds, or format seconds into a duration string |
|
|
155
155
|
|
|
156
|
-
### Units (
|
|
156
|
+
### Units (20 tools)
|
|
157
157
|
| Tool | Description |
|
|
158
158
|
|------|-------------|
|
|
159
159
|
| `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
|
|
@@ -175,6 +175,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
175
175
|
| `convert_subtitles` | Convert subtitles between SubRip (.srt) and WebVTT (.vtt), with an optional time shift to resync every cue |
|
|
176
176
|
| `calculate_break_even` | Compute the break-even unit count and revenue from fixed costs, variable cost per unit, and price per unit |
|
|
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
|
+
| `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 |
|
|
178
179
|
|
|
179
180
|
### API / Network (11 tools)
|
|
180
181
|
| Tool | Description |
|
|
@@ -278,11 +279,17 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
278
279
|
|------|-------------|
|
|
279
280
|
| `read_pdf_metadata` | Read title, author, dates, page count, and encryption flag from a PDF's trailer/Info dictionary: no PDF rendering library required |
|
|
280
281
|
|
|
281
|
-
### Audio (
|
|
282
|
+
### Audio (3 tools)
|
|
282
283
|
| Tool | Description |
|
|
283
284
|
|------|-------------|
|
|
284
285
|
| `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 |
|
|
285
286
|
| `read_id3_tags` | Read title, artist, album, year, genre, comment, and track number from an MP3's ID3 tags: no audio library required |
|
|
287
|
+
| `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
|
+
|
|
289
|
+
### Video (1 tool)
|
|
290
|
+
| Tool | Description |
|
|
291
|
+
|------|-------------|
|
|
292
|
+
| `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 |
|
|
286
293
|
|
|
287
294
|
## Requirements
|
|
288
295
|
|
package/dist/index.js
CHANGED
|
@@ -3228,6 +3228,106 @@ server.tool("read_id3_tags", "Read ID3 tags (title, artist, album, year, genre,
|
|
|
3228
3228
|
if ("error" in result) return errText(result);
|
|
3229
3229
|
return text(JSON.stringify(result, null, 2));
|
|
3230
3230
|
});
|
|
3231
|
+
var _FLAC_TAG_FIELDS = {
|
|
3232
|
+
TITLE: "title",
|
|
3233
|
+
ARTIST: "artist",
|
|
3234
|
+
ALBUM: "album",
|
|
3235
|
+
DATE: "date",
|
|
3236
|
+
GENRE: "genre",
|
|
3237
|
+
TRACKNUMBER: "trackNumber"
|
|
3238
|
+
};
|
|
3239
|
+
function _flacReadAscii4(bytes, offset) {
|
|
3240
|
+
return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
|
|
3241
|
+
}
|
|
3242
|
+
function _flacReadU24BE(bytes, offset) {
|
|
3243
|
+
return bytes[offset] << 16 | bytes[offset + 1] << 8 | bytes[offset + 2];
|
|
3244
|
+
}
|
|
3245
|
+
function _readFlacVorbisComment(bytes, start, end) {
|
|
3246
|
+
let offset = start;
|
|
3247
|
+
if (offset + 4 > end) return null;
|
|
3248
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3249
|
+
const vendorLength = view.getUint32(offset, true);
|
|
3250
|
+
offset += 4;
|
|
3251
|
+
if (offset + vendorLength > end) return null;
|
|
3252
|
+
const vendor = Buffer.from(bytes.subarray(offset, offset + vendorLength)).toString("utf8");
|
|
3253
|
+
offset += vendorLength;
|
|
3254
|
+
if (offset + 4 > end) return null;
|
|
3255
|
+
const commentCount = view.getUint32(offset, true);
|
|
3256
|
+
offset += 4;
|
|
3257
|
+
const tags = {};
|
|
3258
|
+
for (let i = 0; i < commentCount; i++) {
|
|
3259
|
+
if (offset + 4 > end) break;
|
|
3260
|
+
const commentLength = view.getUint32(offset, true);
|
|
3261
|
+
offset += 4;
|
|
3262
|
+
if (offset + commentLength > end) break;
|
|
3263
|
+
const comment = Buffer.from(bytes.subarray(offset, offset + commentLength)).toString("utf8");
|
|
3264
|
+
offset += commentLength;
|
|
3265
|
+
const eq = comment.indexOf("=");
|
|
3266
|
+
if (eq > 0) tags[comment.slice(0, eq).toUpperCase()] = comment.slice(eq + 1);
|
|
3267
|
+
}
|
|
3268
|
+
return { vendor, tags };
|
|
3269
|
+
}
|
|
3270
|
+
function _readFlacInfo(bytes) {
|
|
3271
|
+
if (bytes.length < 4) return { error: "File is too small to be a valid FLAC file" };
|
|
3272
|
+
if (_flacReadAscii4(bytes, 0) !== "fLaC") return { error: 'Not a FLAC file (missing "fLaC" marker)' };
|
|
3273
|
+
let offset = 4;
|
|
3274
|
+
let streamInfo = null;
|
|
3275
|
+
let vorbisComment = null;
|
|
3276
|
+
while (offset + 4 <= bytes.length) {
|
|
3277
|
+
const header = bytes[offset];
|
|
3278
|
+
const isLast = (header & 128) !== 0;
|
|
3279
|
+
const blockType = header & 127;
|
|
3280
|
+
const length = _flacReadU24BE(bytes, offset + 1);
|
|
3281
|
+
const dataStart = offset + 4;
|
|
3282
|
+
const dataEnd = dataStart + length;
|
|
3283
|
+
if (dataEnd > bytes.length) return { error: "Truncated metadata block" };
|
|
3284
|
+
if (blockType === 0) {
|
|
3285
|
+
if (length < 34) return { error: "Truncated STREAMINFO block" };
|
|
3286
|
+
const minBlockSize = bytes[dataStart] << 8 | bytes[dataStart + 1];
|
|
3287
|
+
const maxBlockSize = bytes[dataStart + 2] << 8 | bytes[dataStart + 3];
|
|
3288
|
+
const minFrameSize = _flacReadU24BE(bytes, dataStart + 4);
|
|
3289
|
+
const maxFrameSize = _flacReadU24BE(bytes, dataStart + 7);
|
|
3290
|
+
let packed = 0n;
|
|
3291
|
+
for (let i = 0; i < 8; i++) packed = packed << 8n | BigInt(bytes[dataStart + 10 + i]);
|
|
3292
|
+
const sampleRate = Number(packed >> 44n & 0xfffffn);
|
|
3293
|
+
const channels = Number(packed >> 41n & 0x7n) + 1;
|
|
3294
|
+
const bitsPerSample = Number(packed >> 36n & 0x1fn) + 1;
|
|
3295
|
+
const totalSamples = Number(packed & 0xfffffffffn);
|
|
3296
|
+
if (sampleRate <= 0) return { error: "Invalid sample rate in STREAMINFO block" };
|
|
3297
|
+
streamInfo = {
|
|
3298
|
+
sampleRate,
|
|
3299
|
+
channels,
|
|
3300
|
+
bitsPerSample,
|
|
3301
|
+
totalSamples,
|
|
3302
|
+
durationSeconds: Math.round(totalSamples / sampleRate * 1e3) / 1e3,
|
|
3303
|
+
minBlockSize,
|
|
3304
|
+
maxBlockSize,
|
|
3305
|
+
minFrameSize,
|
|
3306
|
+
maxFrameSize
|
|
3307
|
+
};
|
|
3308
|
+
} else if (blockType === 4) {
|
|
3309
|
+
vorbisComment = _readFlacVorbisComment(bytes, dataStart, dataEnd);
|
|
3310
|
+
}
|
|
3311
|
+
offset = dataEnd;
|
|
3312
|
+
if (isLast) break;
|
|
3313
|
+
}
|
|
3314
|
+
if (!streamInfo) return { error: "No STREAMINFO block found in this FLAC file" };
|
|
3315
|
+
const result = { ...streamInfo };
|
|
3316
|
+
if (vorbisComment) {
|
|
3317
|
+
result.vendor = vorbisComment.vendor;
|
|
3318
|
+
result.tags = vorbisComment.tags;
|
|
3319
|
+
for (const [key, field] of Object.entries(_FLAC_TAG_FIELDS)) {
|
|
3320
|
+
if (vorbisComment.tags[key] !== void 0) result[field] = vorbisComment.tags[key];
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
return result;
|
|
3324
|
+
}
|
|
3325
|
+
server.tool("read_flac_info", "Read sample rate, channels, bit depth, total samples, and duration from a FLAC file's STREAMINFO metadata block, plus Vorbis comment tags (ARTIST, TITLE, ALBUM, DATE, GENRE, TRACKNUMBER) from its VORBIS_COMMENT block if present. No audio decoding library required.", { flac_base64: z.string().describe("Base64-encoded FLAC file bytes, optionally prefixed with a data: URL scheme") }, async ({ flac_base64 }) => {
|
|
3326
|
+
const bytes = new Uint8Array(Buffer.from(flac_base64.replace(/^data:.*;base64,/, ""), "base64"));
|
|
3327
|
+
const result = _readFlacInfo(bytes);
|
|
3328
|
+
if ("error" in result) return errText(result);
|
|
3329
|
+
return text(JSON.stringify(result, null, 2));
|
|
3330
|
+
});
|
|
3231
3331
|
function _readU16LE(bytes, offset) {
|
|
3232
3332
|
return bytes[offset] | bytes[offset + 1] << 8;
|
|
3233
3333
|
}
|
|
@@ -4556,5 +4656,308 @@ server.tool(
|
|
|
4556
4656
|
return text(JSON.stringify(result, null, 2));
|
|
4557
4657
|
}
|
|
4558
4658
|
);
|
|
4659
|
+
function _calculateRuleOf72Mcp(value, direction) {
|
|
4660
|
+
if (!Number.isFinite(value) || value <= 0) return { error: "Value must be a positive number" };
|
|
4661
|
+
const round4 = (n) => Math.round(n * 1e4) / 1e4;
|
|
4662
|
+
if (direction === "rate-to-years") {
|
|
4663
|
+
const ratePercent = value;
|
|
4664
|
+
const ruleOf72Estimate2 = 72 / ratePercent;
|
|
4665
|
+
const exactValue2 = Math.log(2) / Math.log(1 + ratePercent / 100);
|
|
4666
|
+
const errorPercent2 = (ruleOf72Estimate2 - exactValue2) / exactValue2 * 100;
|
|
4667
|
+
return {
|
|
4668
|
+
direction,
|
|
4669
|
+
ratePercent: round4(ratePercent),
|
|
4670
|
+
years: round4(exactValue2),
|
|
4671
|
+
ruleOf72Estimate: round4(ruleOf72Estimate2),
|
|
4672
|
+
exactValue: round4(exactValue2),
|
|
4673
|
+
errorPercent: round4(errorPercent2)
|
|
4674
|
+
};
|
|
4675
|
+
}
|
|
4676
|
+
const years = value;
|
|
4677
|
+
const ruleOf72Estimate = 72 / years;
|
|
4678
|
+
const exactValue = (Math.pow(2, 1 / years) - 1) * 100;
|
|
4679
|
+
const errorPercent = (ruleOf72Estimate - exactValue) / exactValue * 100;
|
|
4680
|
+
return {
|
|
4681
|
+
direction,
|
|
4682
|
+
ratePercent: round4(exactValue),
|
|
4683
|
+
years: round4(years),
|
|
4684
|
+
ruleOf72Estimate: round4(ruleOf72Estimate),
|
|
4685
|
+
exactValue: round4(exactValue),
|
|
4686
|
+
errorPercent: round4(errorPercent)
|
|
4687
|
+
};
|
|
4688
|
+
}
|
|
4689
|
+
server.tool(
|
|
4690
|
+
"calculate_rule_of_72",
|
|
4691
|
+
'Estimate how many years it takes an investment to double at a given annual rate (direction "rate-to-years"), or the rate needed to double in a given number of years (direction "years-to-rate"), using the Rule of 72 shortcut (72 / rate, or 72 / years). Also returns the exact result from the logarithmic doubling-time formula ln(2) / ln(1 + rate/100) plus the approximation error, since the Rule of 72 is most accurate for rates roughly between 6% and 10%.',
|
|
4692
|
+
{
|
|
4693
|
+
value: z.number().describe("A rate percent (for rate-to-years) or a number of years (for years-to-rate); must be positive"),
|
|
4694
|
+
direction: z.enum(["rate-to-years", "years-to-rate"])
|
|
4695
|
+
},
|
|
4696
|
+
async ({ value, direction }) => {
|
|
4697
|
+
const result = _calculateRuleOf72Mcp(value, direction);
|
|
4698
|
+
if ("error" in result) return errText(result);
|
|
4699
|
+
return text(JSON.stringify(result, null, 2));
|
|
4700
|
+
}
|
|
4701
|
+
);
|
|
4702
|
+
function _videoReadU32BE(bytes, offset) {
|
|
4703
|
+
return (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
|
|
4704
|
+
}
|
|
4705
|
+
function _videoReadU64BE(bytes, offset) {
|
|
4706
|
+
return _videoReadU32BE(bytes, offset) * 4294967296 + _videoReadU32BE(bytes, offset + 4);
|
|
4707
|
+
}
|
|
4708
|
+
function _videoReadAscii4(bytes, offset) {
|
|
4709
|
+
return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
|
|
4710
|
+
}
|
|
4711
|
+
function _readMp4Boxes(bytes, start, end) {
|
|
4712
|
+
const boxes = [];
|
|
4713
|
+
let offset = start;
|
|
4714
|
+
while (offset + 8 <= end) {
|
|
4715
|
+
let size = _videoReadU32BE(bytes, offset);
|
|
4716
|
+
const type = _videoReadAscii4(bytes, offset + 4);
|
|
4717
|
+
let headerSize = 8;
|
|
4718
|
+
if (size === 1) {
|
|
4719
|
+
if (offset + 16 > end) break;
|
|
4720
|
+
size = _videoReadU64BE(bytes, offset + 8);
|
|
4721
|
+
headerSize = 16;
|
|
4722
|
+
} else if (size === 0) {
|
|
4723
|
+
size = end - offset;
|
|
4724
|
+
}
|
|
4725
|
+
if (size < headerSize || offset + size > end) break;
|
|
4726
|
+
boxes.push({ type, start: offset + headerSize, end: offset + size });
|
|
4727
|
+
offset += size;
|
|
4728
|
+
}
|
|
4729
|
+
return boxes;
|
|
4730
|
+
}
|
|
4731
|
+
function _findMp4Box(boxes, type) {
|
|
4732
|
+
return boxes.find((b) => b.type === type);
|
|
4733
|
+
}
|
|
4734
|
+
function _parseMp4Mvhd(bytes, box) {
|
|
4735
|
+
const o = box.start;
|
|
4736
|
+
if (o >= box.end) return null;
|
|
4737
|
+
const version = bytes[o];
|
|
4738
|
+
if (version === 1) {
|
|
4739
|
+
if (o + 32 > box.end) return null;
|
|
4740
|
+
return { timescale: _videoReadU32BE(bytes, o + 20), duration: _videoReadU64BE(bytes, o + 24) };
|
|
4741
|
+
}
|
|
4742
|
+
if (o + 20 > box.end) return null;
|
|
4743
|
+
return { timescale: _videoReadU32BE(bytes, o + 12), duration: _videoReadU32BE(bytes, o + 16) };
|
|
4744
|
+
}
|
|
4745
|
+
function _parseMp4Tkhd(bytes, box) {
|
|
4746
|
+
const o = box.start;
|
|
4747
|
+
if (o >= box.end) return null;
|
|
4748
|
+
const version = bytes[o];
|
|
4749
|
+
const fixedFieldsSize = version === 1 ? 4 + 8 + 8 + 4 + 4 + 8 : 4 + 4 + 4 + 4 + 4 + 4;
|
|
4750
|
+
const afterDuration = o + fixedFieldsSize;
|
|
4751
|
+
const matrixStart = afterDuration + 8 + 2 + 2 + 2 + 2;
|
|
4752
|
+
const widthOffset = matrixStart + 36;
|
|
4753
|
+
if (widthOffset + 8 > box.end) return null;
|
|
4754
|
+
return { width: _videoReadU32BE(bytes, widthOffset) / 65536, height: _videoReadU32BE(bytes, widthOffset + 4) / 65536 };
|
|
4755
|
+
}
|
|
4756
|
+
function _parseMp4Hdlr(bytes, box) {
|
|
4757
|
+
const o = box.start;
|
|
4758
|
+
if (o + 12 > box.end) return null;
|
|
4759
|
+
return _videoReadAscii4(bytes, o + 8);
|
|
4760
|
+
}
|
|
4761
|
+
function _parseMp4StsdCodec(bytes, box) {
|
|
4762
|
+
const o = box.start;
|
|
4763
|
+
if (o + 8 > box.end) return null;
|
|
4764
|
+
const entryCount = _videoReadU32BE(bytes, o + 4);
|
|
4765
|
+
if (entryCount < 1) return null;
|
|
4766
|
+
const entryStart = o + 8;
|
|
4767
|
+
if (entryStart + 8 > box.end) return null;
|
|
4768
|
+
return _videoReadAscii4(bytes, entryStart + 4);
|
|
4769
|
+
}
|
|
4770
|
+
function _parseMp4StsdVideoDims(bytes, box) {
|
|
4771
|
+
const o = box.start;
|
|
4772
|
+
if (o + 8 > box.end) return null;
|
|
4773
|
+
const entryCount = _videoReadU32BE(bytes, o + 4);
|
|
4774
|
+
if (entryCount < 1) return null;
|
|
4775
|
+
const entryStart = o + 8;
|
|
4776
|
+
if (entryStart + 36 > box.end) return null;
|
|
4777
|
+
return {
|
|
4778
|
+
width: bytes[entryStart + 32] << 8 | bytes[entryStart + 33],
|
|
4779
|
+
height: bytes[entryStart + 34] << 8 | bytes[entryStart + 35]
|
|
4780
|
+
};
|
|
4781
|
+
}
|
|
4782
|
+
function _parseMp4Video(bytes) {
|
|
4783
|
+
const topBoxes = _readMp4Boxes(bytes, 0, bytes.length);
|
|
4784
|
+
const ftyp = _findMp4Box(topBoxes, "ftyp");
|
|
4785
|
+
const moov = _findMp4Box(topBoxes, "moov");
|
|
4786
|
+
if (!moov) return { error: "No moov box found (not a valid or fully-downloaded MP4 file)" };
|
|
4787
|
+
const result = { format: "mp4" };
|
|
4788
|
+
if (ftyp && ftyp.start + 4 <= ftyp.end) result.majorBrand = _videoReadAscii4(bytes, ftyp.start);
|
|
4789
|
+
const moovBoxes = _readMp4Boxes(bytes, moov.start, moov.end);
|
|
4790
|
+
const mvhd = _findMp4Box(moovBoxes, "mvhd");
|
|
4791
|
+
if (mvhd) {
|
|
4792
|
+
const info = _parseMp4Mvhd(bytes, mvhd);
|
|
4793
|
+
if (info && info.timescale > 0) result.durationSeconds = Math.round(info.duration / info.timescale * 1e3) / 1e3;
|
|
4794
|
+
}
|
|
4795
|
+
for (const trak of moovBoxes.filter((b) => b.type === "trak")) {
|
|
4796
|
+
const trakBoxes = _readMp4Boxes(bytes, trak.start, trak.end);
|
|
4797
|
+
const mdia = _findMp4Box(trakBoxes, "mdia");
|
|
4798
|
+
if (!mdia) continue;
|
|
4799
|
+
const mdiaBoxes = _readMp4Boxes(bytes, mdia.start, mdia.end);
|
|
4800
|
+
const hdlr = _findMp4Box(mdiaBoxes, "hdlr");
|
|
4801
|
+
const handlerType = hdlr ? _parseMp4Hdlr(bytes, hdlr) : null;
|
|
4802
|
+
const minf = _findMp4Box(mdiaBoxes, "minf");
|
|
4803
|
+
if (!minf) continue;
|
|
4804
|
+
const stbl = _findMp4Box(_readMp4Boxes(bytes, minf.start, minf.end), "stbl");
|
|
4805
|
+
if (!stbl) continue;
|
|
4806
|
+
const stsd = _findMp4Box(_readMp4Boxes(bytes, stbl.start, stbl.end), "stsd");
|
|
4807
|
+
const codec = stsd ? _parseMp4StsdCodec(bytes, stsd) : null;
|
|
4808
|
+
if (handlerType === "vide") {
|
|
4809
|
+
if (codec) result.videoCodec = codec;
|
|
4810
|
+
const tkhd = _findMp4Box(trakBoxes, "tkhd");
|
|
4811
|
+
const dims = tkhd ? _parseMp4Tkhd(bytes, tkhd) : null;
|
|
4812
|
+
if (dims && dims.width > 0 && dims.height > 0) {
|
|
4813
|
+
result.width = Math.round(dims.width);
|
|
4814
|
+
result.height = Math.round(dims.height);
|
|
4815
|
+
} else if (stsd) {
|
|
4816
|
+
const pixelDims = _parseMp4StsdVideoDims(bytes, stsd);
|
|
4817
|
+
if (pixelDims && pixelDims.width > 0 && pixelDims.height > 0) {
|
|
4818
|
+
result.width = pixelDims.width;
|
|
4819
|
+
result.height = pixelDims.height;
|
|
4820
|
+
}
|
|
4821
|
+
}
|
|
4822
|
+
} else if (handlerType === "soun") {
|
|
4823
|
+
if (codec) result.audioCodec = codec;
|
|
4824
|
+
}
|
|
4825
|
+
}
|
|
4826
|
+
if (result.durationSeconds === void 0 && !result.videoCodec && !result.audioCodec) {
|
|
4827
|
+
return { error: "Could not find any usable track metadata in this MP4 file's moov box" };
|
|
4828
|
+
}
|
|
4829
|
+
return result;
|
|
4830
|
+
}
|
|
4831
|
+
function _ebmlVintLength(firstByte) {
|
|
4832
|
+
if (firstByte === 0) return 0;
|
|
4833
|
+
let mask = 128;
|
|
4834
|
+
let length = 1;
|
|
4835
|
+
while (length <= 8 && (firstByte & mask) === 0) {
|
|
4836
|
+
mask >>= 1;
|
|
4837
|
+
length++;
|
|
4838
|
+
}
|
|
4839
|
+
return length;
|
|
4840
|
+
}
|
|
4841
|
+
function _readEbmlId(bytes, offset) {
|
|
4842
|
+
if (offset >= bytes.length) return null;
|
|
4843
|
+
const length = _ebmlVintLength(bytes[offset]);
|
|
4844
|
+
if (length === 0 || offset + length > bytes.length) return null;
|
|
4845
|
+
let id = 0;
|
|
4846
|
+
for (let i = 0; i < length; i++) id = id * 256 + bytes[offset + i];
|
|
4847
|
+
return { id, length };
|
|
4848
|
+
}
|
|
4849
|
+
function _readEbmlSize(bytes, offset) {
|
|
4850
|
+
if (offset >= bytes.length) return null;
|
|
4851
|
+
const length = _ebmlVintLength(bytes[offset]);
|
|
4852
|
+
if (length === 0 || offset + length > bytes.length) return null;
|
|
4853
|
+
const mask = 255 >> length;
|
|
4854
|
+
let value = bytes[offset] & mask;
|
|
4855
|
+
let allOnes = value === mask;
|
|
4856
|
+
for (let i = 1; i < length; i++) {
|
|
4857
|
+
value = value * 256 + bytes[offset + i];
|
|
4858
|
+
if (bytes[offset + i] !== 255) allOnes = false;
|
|
4859
|
+
}
|
|
4860
|
+
return { value, length, unknown: allOnes };
|
|
4861
|
+
}
|
|
4862
|
+
function _readEbmlUint(bytes, offset, length) {
|
|
4863
|
+
let value = 0;
|
|
4864
|
+
for (let i = 0; i < length; i++) value = value * 256 + bytes[offset + i];
|
|
4865
|
+
return value;
|
|
4866
|
+
}
|
|
4867
|
+
function _readEbmlFloat(bytes, offset, length) {
|
|
4868
|
+
if (offset + length > bytes.length) return 0;
|
|
4869
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset + offset, length);
|
|
4870
|
+
if (length === 4) return view.getFloat32(0, false);
|
|
4871
|
+
if (length === 8) return view.getFloat64(0, false);
|
|
4872
|
+
return 0;
|
|
4873
|
+
}
|
|
4874
|
+
function _ebmlChildren(bytes, start, end) {
|
|
4875
|
+
const children = [];
|
|
4876
|
+
let offset = start;
|
|
4877
|
+
while (offset < end) {
|
|
4878
|
+
const idInfo = _readEbmlId(bytes, offset);
|
|
4879
|
+
if (!idInfo) break;
|
|
4880
|
+
const sizeInfo = _readEbmlSize(bytes, offset + idInfo.length);
|
|
4881
|
+
if (!sizeInfo) break;
|
|
4882
|
+
const dataStart = offset + idInfo.length + sizeInfo.length;
|
|
4883
|
+
const dataEnd = sizeInfo.unknown ? end : Math.min(dataStart + sizeInfo.value, end);
|
|
4884
|
+
children.push({ id: idInfo.id, dataStart, dataEnd });
|
|
4885
|
+
offset = dataEnd;
|
|
4886
|
+
}
|
|
4887
|
+
return children;
|
|
4888
|
+
}
|
|
4889
|
+
var _EBML_ID_SEGMENT = 408125543;
|
|
4890
|
+
var _EBML_ID_INFO = 357149030;
|
|
4891
|
+
var _EBML_ID_TIMECODE_SCALE = 2807729;
|
|
4892
|
+
var _EBML_ID_DURATION = 17545;
|
|
4893
|
+
var _EBML_ID_TRACKS = 374648427;
|
|
4894
|
+
var _EBML_ID_TRACK_ENTRY = 174;
|
|
4895
|
+
var _EBML_ID_TRACK_TYPE = 131;
|
|
4896
|
+
var _EBML_ID_CODEC_ID = 134;
|
|
4897
|
+
var _EBML_ID_VIDEO = 224;
|
|
4898
|
+
var _EBML_ID_PIXEL_WIDTH = 176;
|
|
4899
|
+
var _EBML_ID_PIXEL_HEIGHT = 186;
|
|
4900
|
+
var _EBML_ID_CLUSTER = 524531317;
|
|
4901
|
+
function _parseWebmVideo(bytes) {
|
|
4902
|
+
const top = _ebmlChildren(bytes, 0, bytes.length);
|
|
4903
|
+
const segment = top.find((c) => c.id === _EBML_ID_SEGMENT);
|
|
4904
|
+
if (!segment) return { error: "No Segment element found (not a valid WebM/Matroska file)" };
|
|
4905
|
+
const result = { format: "webm" };
|
|
4906
|
+
let timecodeScale = 1e6;
|
|
4907
|
+
let rawDuration;
|
|
4908
|
+
for (const child of _ebmlChildren(bytes, segment.dataStart, segment.dataEnd)) {
|
|
4909
|
+
if (child.id === _EBML_ID_CLUSTER) break;
|
|
4910
|
+
if (child.id === _EBML_ID_INFO) {
|
|
4911
|
+
for (const f of _ebmlChildren(bytes, child.dataStart, child.dataEnd)) {
|
|
4912
|
+
if (f.id === _EBML_ID_TIMECODE_SCALE) timecodeScale = _readEbmlUint(bytes, f.dataStart, f.dataEnd - f.dataStart) || 1e6;
|
|
4913
|
+
else if (f.id === _EBML_ID_DURATION) rawDuration = _readEbmlFloat(bytes, f.dataStart, f.dataEnd - f.dataStart);
|
|
4914
|
+
}
|
|
4915
|
+
} else if (child.id === _EBML_ID_TRACKS) {
|
|
4916
|
+
for (const entry of _ebmlChildren(bytes, child.dataStart, child.dataEnd)) {
|
|
4917
|
+
if (entry.id !== _EBML_ID_TRACK_ENTRY) continue;
|
|
4918
|
+
let trackType;
|
|
4919
|
+
let codecId;
|
|
4920
|
+
let pixelWidth;
|
|
4921
|
+
let pixelHeight;
|
|
4922
|
+
for (const f of _ebmlChildren(bytes, entry.dataStart, entry.dataEnd)) {
|
|
4923
|
+
if (f.id === _EBML_ID_TRACK_TYPE) trackType = _readEbmlUint(bytes, f.dataStart, f.dataEnd - f.dataStart);
|
|
4924
|
+
else if (f.id === _EBML_ID_CODEC_ID) codecId = Buffer.from(bytes.subarray(f.dataStart, f.dataEnd)).toString("ascii").replace(/\0+$/, "");
|
|
4925
|
+
else if (f.id === _EBML_ID_VIDEO) {
|
|
4926
|
+
for (const v of _ebmlChildren(bytes, f.dataStart, f.dataEnd)) {
|
|
4927
|
+
if (v.id === _EBML_ID_PIXEL_WIDTH) pixelWidth = _readEbmlUint(bytes, v.dataStart, v.dataEnd - v.dataStart);
|
|
4928
|
+
else if (v.id === _EBML_ID_PIXEL_HEIGHT) pixelHeight = _readEbmlUint(bytes, v.dataStart, v.dataEnd - v.dataStart);
|
|
4929
|
+
}
|
|
4930
|
+
}
|
|
4931
|
+
}
|
|
4932
|
+
if (trackType === 1) {
|
|
4933
|
+
if (codecId) result.videoCodec = codecId;
|
|
4934
|
+
if (pixelWidth) result.width = pixelWidth;
|
|
4935
|
+
if (pixelHeight) result.height = pixelHeight;
|
|
4936
|
+
} else if (trackType === 2) {
|
|
4937
|
+
if (codecId) result.audioCodec = codecId;
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
if (rawDuration !== void 0 && timecodeScale > 0) {
|
|
4943
|
+
result.durationSeconds = Math.round(rawDuration * timecodeScale / 1e9 * 1e3) / 1e3;
|
|
4944
|
+
}
|
|
4945
|
+
if (result.durationSeconds === void 0 && !result.videoCodec && !result.audioCodec) {
|
|
4946
|
+
return { error: "Could not find any usable metadata in this WebM file's Segment element" };
|
|
4947
|
+
}
|
|
4948
|
+
return result;
|
|
4949
|
+
}
|
|
4950
|
+
function _readVideoInfo(bytes) {
|
|
4951
|
+
if (bytes.length < 12) return { error: "File is too small to be a valid video file" };
|
|
4952
|
+
if (_videoReadAscii4(bytes, 4) === "ftyp") return _parseMp4Video(bytes);
|
|
4953
|
+
if (bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163) return _parseWebmVideo(bytes);
|
|
4954
|
+
return { error: 'Not a recognized MP4 or WebM file (missing an "ftyp" box or an EBML header signature)' };
|
|
4955
|
+
}
|
|
4956
|
+
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 }) => {
|
|
4957
|
+
const bytes = new Uint8Array(Buffer.from(video_base64.replace(/^data:.*;base64,/, ""), "base64"));
|
|
4958
|
+
const result = _readVideoInfo(bytes);
|
|
4959
|
+
if ("error" in result) return errText(result);
|
|
4960
|
+
return text(JSON.stringify(result, null, 2));
|
|
4961
|
+
});
|
|
4559
4962
|
var transport = new StdioServerTransport();
|
|
4560
4963
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@utilix-tech/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server exposing
|
|
3
|
+
"version": "0.29.0",
|
|
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",
|
|
7
7
|
"homepage": "https://utilix.tech/mcp",
|