@utilix-tech/mcp 0.26.0 → 0.28.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,14 @@
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.28.0
6
+ - 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
+ - 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.
8
+
9
+ ## 0.27.0
10
+ - Add `compute_jwk_thumbprint` tool: RFC 7638 canonical thumbprint of a single JWK, hashed with SHA-256/384/512.
11
+ - 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.
12
+
5
13
  ## 0.26.0
6
14
  - Add `calculate_break_even` tool: break-even unit count and revenue from fixed costs, variable cost per unit, and price per unit, plus profit and margin of safety at an optional sales volume.
7
15
  - Add `calculate_string_distance` tool: Levenshtein edit distance and normalized similarity between two strings, with an optional Damerau (transposition) mode.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 137 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
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.
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 (18 tools)
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 |
@@ -174,12 +174,15 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
174
174
  | `calculate_discount` | Solve a sale price, amount saved, or discount percent from any one of them, or stack successive discounts in order |
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
+ | `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 |
177
179
 
178
- ### API / Network (10 tools)
180
+ ### API / Network (11 tools)
179
181
  | Tool | Description |
180
182
  |------|-------------|
181
183
  | `decode_jwt` | Decode a JWT: header, payload, and expiry |
182
184
  | `parse_jwks` | Parse a JWKS (or a single bare JWK): type, use, algorithm, key ID, and size per key |
185
+ | `compute_jwk_thumbprint` | Compute the RFC 7638 canonical thumbprint of a single JWK (SHA-256/384/512) |
183
186
  | `build_curl` | Build a cURL command from method, URL, headers, and body |
184
187
  | `curl_to_code` | Convert a cURL command to fetch, axios, Python, Go, PHP, or Ruby |
185
188
  | `cors_headers` | Generate CORS response headers for a configuration |
@@ -276,11 +279,12 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
276
279
  |------|-------------|
277
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 |
278
281
 
279
- ### Audio (2 tools)
282
+ ### Audio (3 tools)
280
283
  | Tool | Description |
281
284
  |------|-------------|
282
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 |
283
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 |
284
288
 
285
289
  ## Requirements
286
290
 
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { z } from "zod";
7
+ import { createHash } from "crypto";
7
8
  import {
8
9
  parseJson,
9
10
  minifyJson,
@@ -2826,6 +2827,61 @@ server.tool("parse_jwks", "Parse a JSON Web Key Set (or a single bare JWK) and s
2826
2827
  if ("error" in result) return errText(result);
2827
2828
  return text(JSON.stringify(result, null, 2));
2828
2829
  });
2830
+ var _JWK_THUMBPRINT_MEMBERS = {
2831
+ oct: ["k", "kty"],
2832
+ RSA: ["e", "kty", "n"],
2833
+ EC: ["crv", "kty", "x", "y"],
2834
+ OKP: ["crv", "kty", "x"]
2835
+ };
2836
+ var _JWK_THUMBPRINT_NODE_HASH = {
2837
+ "SHA-256": "sha256",
2838
+ "SHA-384": "sha384",
2839
+ "SHA-512": "sha512"
2840
+ };
2841
+ function _computeJwkThumbprint(input, hashAlg) {
2842
+ const trimmed = input.trim();
2843
+ if (!trimmed) return { error: "No input provided" };
2844
+ const nodeHash = _JWK_THUMBPRINT_NODE_HASH[hashAlg];
2845
+ if (!nodeHash) return { error: 'hashAlg must be one of "SHA-256", "SHA-384", "SHA-512"' };
2846
+ let jwk;
2847
+ try {
2848
+ jwk = JSON.parse(trimmed);
2849
+ } catch (e) {
2850
+ return { error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` };
2851
+ }
2852
+ if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) {
2853
+ return { error: "Input must be a single JWK object, not a JWKS set or array" };
2854
+ }
2855
+ const obj = jwk;
2856
+ const kty = obj.kty;
2857
+ if (typeof kty !== "string") return { error: 'JWK must have a "kty" (key type) member' };
2858
+ const members = _JWK_THUMBPRINT_MEMBERS[kty];
2859
+ if (!members) return { error: `Unsupported key type "${kty}": supported types are RSA, EC, oct, OKP` };
2860
+ const canonical = {};
2861
+ for (const m of members) {
2862
+ const v = obj[m];
2863
+ if (typeof v !== "string" || v.length === 0) {
2864
+ return { error: `JWK is missing required member "${m}" for key type "${kty}"` };
2865
+ }
2866
+ canonical[m] = v;
2867
+ }
2868
+ const canonicalJson = "{" + members.map((m) => `${JSON.stringify(m)}:${JSON.stringify(canonical[m])}`).join(",") + "}";
2869
+ const thumbprint = createHash(nodeHash).update(canonicalJson, "utf8").digest("base64url");
2870
+ return { kty, hashAlg, members, canonicalJson, thumbprint };
2871
+ }
2872
+ server.tool(
2873
+ "compute_jwk_thumbprint",
2874
+ "Compute the RFC 7638 thumbprint of a single JWK: canonicalize its required members into lexicographically-ordered, whitespace-free JSON and hash it. A fingerprint for identifying/matching a key, not a signature or verification. Accepts a bare JWK only, not a JWKS set.",
2875
+ {
2876
+ input: z.string().describe("A single JWK object (not a JWKS set)"),
2877
+ hashAlg: z.enum(["SHA-256", "SHA-384", "SHA-512"]).default("SHA-256")
2878
+ },
2879
+ async ({ input, hashAlg }) => {
2880
+ const result = _computeJwkThumbprint(input, hashAlg);
2881
+ if ("error" in result) return errText(result);
2882
+ return text(JSON.stringify(result, null, 2));
2883
+ }
2884
+ );
2829
2885
  var _WAV_AUDIO_FORMAT_LABELS = { 1: "PCM", 3: "IEEE float", 6: "A-law", 7: "mu-law", 65534: "Extensible" };
2830
2886
  function _readWavAscii4(bytes, offset) {
2831
2887
  return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
@@ -3172,6 +3228,106 @@ server.tool("read_id3_tags", "Read ID3 tags (title, artist, album, year, genre,
3172
3228
  if ("error" in result) return errText(result);
3173
3229
  return text(JSON.stringify(result, null, 2));
3174
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
+ });
3175
3331
  function _readU16LE(bytes, offset) {
3176
3332
  return bytes[offset] | bytes[offset + 1] << 8;
3177
3333
  }
@@ -4436,5 +4592,112 @@ server.tool(
4436
4592
  return text(JSON.stringify(result, null, 2));
4437
4593
  }
4438
4594
  );
4595
+ function _calculateVideoBitrateMcp(mode, durationSeconds, videoBitrateKbps, audioBitrateKbps, targetSizeMB) {
4596
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
4597
+ return { error: "durationSeconds must be a positive number" };
4598
+ }
4599
+ if (!Number.isFinite(audioBitrateKbps) || audioBitrateKbps < 0) {
4600
+ return { error: "audioBitrateKbps must be zero or a positive number" };
4601
+ }
4602
+ const round2 = (n) => Math.round(n * 100) / 100;
4603
+ if (mode === "size-from-bitrate") {
4604
+ if (typeof videoBitrateKbps !== "number" || !Number.isFinite(videoBitrateKbps) || videoBitrateKbps <= 0) {
4605
+ return { error: 'videoBitrateKbps must be a positive number for mode "size-from-bitrate"' };
4606
+ }
4607
+ const totalBitrateKbps = videoBitrateKbps + audioBitrateKbps;
4608
+ const fileSizeBytes = totalBitrateKbps * 1e3 * durationSeconds / 8;
4609
+ return {
4610
+ mode,
4611
+ durationSeconds,
4612
+ videoBitrateKbps: round2(videoBitrateKbps),
4613
+ audioBitrateKbps: round2(audioBitrateKbps),
4614
+ totalBitrateKbps: round2(totalBitrateKbps),
4615
+ fileSizeBytes: Math.round(fileSizeBytes),
4616
+ fileSizeMB: round2(fileSizeBytes / 1e6),
4617
+ fileSizeMiB: round2(fileSizeBytes / 1048576)
4618
+ };
4619
+ }
4620
+ if (mode === "bitrate-from-size") {
4621
+ if (typeof targetSizeMB !== "number" || !Number.isFinite(targetSizeMB) || targetSizeMB <= 0) {
4622
+ return { error: 'targetSizeMB must be a positive number for mode "bitrate-from-size"' };
4623
+ }
4624
+ const fileSizeBytes = targetSizeMB * 1e6;
4625
+ const totalBitrateKbps = fileSizeBytes * 8 / (1e3 * durationSeconds);
4626
+ const videoKbps = totalBitrateKbps - audioBitrateKbps;
4627
+ if (videoKbps <= 0) {
4628
+ return { error: "audioBitrateKbps exceeds the total bitrate available at this target size and duration; lower the audio bitrate or raise the target size" };
4629
+ }
4630
+ return {
4631
+ mode,
4632
+ durationSeconds,
4633
+ videoBitrateKbps: round2(videoKbps),
4634
+ audioBitrateKbps: round2(audioBitrateKbps),
4635
+ totalBitrateKbps: round2(totalBitrateKbps),
4636
+ fileSizeBytes: Math.round(fileSizeBytes),
4637
+ fileSizeMB: round2(targetSizeMB),
4638
+ fileSizeMiB: round2(fileSizeBytes / 1048576)
4639
+ };
4640
+ }
4641
+ return { error: 'mode must be "size-from-bitrate" or "bitrate-from-size"' };
4642
+ }
4643
+ server.tool(
4644
+ "calculate_video_bitrate",
4645
+ "Calculate the video file size from a target bitrate and duration, or the required bitrate to hit a target file size, with an optional separate audio bitrate. Pure arithmetic (file size bytes = total bitrate bits/sec * duration seconds / 8); no container parsing or encoding is performed.",
4646
+ {
4647
+ mode: z.enum(["size-from-bitrate", "bitrate-from-size"]).describe("size-from-bitrate requires videoBitrateKbps; bitrate-from-size requires targetSizeMB"),
4648
+ durationSeconds: z.number().describe("Video duration in seconds (must be positive)"),
4649
+ videoBitrateKbps: z.number().optional().describe('Required for mode "size-from-bitrate"'),
4650
+ audioBitrateKbps: z.number().default(0).describe("Optional for either mode"),
4651
+ targetSizeMB: z.number().optional().describe('Required for mode "bitrate-from-size"; decimal MB (1 MB = 1,000,000 bytes)')
4652
+ },
4653
+ async ({ mode, durationSeconds, videoBitrateKbps, audioBitrateKbps, targetSizeMB }) => {
4654
+ const result = _calculateVideoBitrateMcp(mode, durationSeconds, videoBitrateKbps, audioBitrateKbps, targetSizeMB);
4655
+ if ("error" in result) return errText(result);
4656
+ return text(JSON.stringify(result, null, 2));
4657
+ }
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
+ );
4439
4702
  var transport = new StdioServerTransport();
4440
4703
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.26.0",
4
- "description": "MCP server exposing 139 Utilix developer tools to Claude, Cursor, VS Code Copilot, and any MCP-compatible AI assistant.",
3
+ "version": "0.28.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",