@utilix-tech/mcp 0.94.0 → 0.96.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.96.0
6
+ - Add `validate_routing_number` tool: validate a 9-digit US bank routing number via its weighted mod-10 checksum, identifying the routing number's type and Federal Reserve district from its first two digits.
7
+ - Add `audit_markdown_image_alt_text` tool: scan a Markdown document's inline images and flag alt text accessibility smells, missing/empty alt text, generic placeholder words, a redundant "image of" prefix, and alt text that's just the image's own filename.
8
+
9
+ ## 0.95.0
10
+ - Add `read_png_chunks` tool: walk a PNG's chunk stream directly from its bytes to report every chunk's type, offset, size, and CRC-32 validity, plus the decoded IHDR header and common ancillary chunks (tEXt, pHYs, gAMA, tIME, acTL).
11
+ - Add `estimate_audio_file_size` tool: estimate an audio file's size before recording or exporting it, uncompressed PCM size from sample rate/bit depth/channels or compressed size from a target bitrate.
12
+
5
13
  ## 0.94.0
6
14
  - Add `build_alt_svc_header`/`parse_alt_svc_header` tools: build an `Alt-Svc` response header (RFC 7838) from one or more alternative services with a plain-English note per protocol-id, flagging outdated HTTP/3 draft protocol-ids, or parse an existing header back into its alternatives, including the special `clear` value.
7
15
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 281 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
3
+ MCP server exposing 285 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
 
@@ -169,7 +169,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
169
169
  | `convert_timezone` | Convert a date/time between timezones |
170
170
  | `convert_iso8601_duration` | Parse an ISO 8601 duration into components and total seconds, or format seconds into a duration string |
171
171
 
172
- ### Units (51 tools)
172
+ ### Units (52 tools)
173
173
  | Tool | Description |
174
174
  |------|-------------|
175
175
  | `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
@@ -223,6 +223,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
223
223
  | `detect_negative_amortization` | Given a loan's balance, annual rate, and a proposed monthly payment, check whether that payment covers the interest accruing each period; if not, report the shortfall and project how the balance grows instead of shrinking |
224
224
  | `calculate_sales_commission` | Compute a sales commission from either a flat percent rate applied to the whole sale amount, or a tiered (marginal) rate structure where each tier applies only to the slice of sales within it, plus an optional base salary added to the total payout |
225
225
  | `intersect_semver_ranges` | Determine whether two semver ranges overlap and, if so, the intersecting range, supporting the same caret/tilde/comparison-operator/exact-version syntax as the semver tool |
226
+ | `validate_routing_number` | Validate a 9-digit US bank routing number (ABA/RTN) via its weighted mod-10 checksum, and identify its routing number type and Federal Reserve district |
226
227
 
227
228
  ### API / Network (55 tools)
228
229
  | Tool | Description |
@@ -328,7 +329,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
328
329
  | `find_duplicate_lockfile_versions` | Parse an npm, yarn, or pnpm lockfile and report every package installed at more than one resolved version, with a count of install locations per version |
329
330
  | `summarize_git_diff_stat` | Parse `git diff --stat` / `git log --stat` output into structured per-file insertion/deletion counts plus overall totals |
330
331
 
331
- ### Data (21 tools)
332
+ ### Data (22 tools)
332
333
  | Tool | Description |
333
334
  |------|-------------|
334
335
  | `validate_yaml` | Validate YAML syntax |
@@ -352,6 +353,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
352
353
  | `validate_markdown_heading_hierarchy` | Scan a Markdown document's ATX headings and flag heading-structure issues: skipped levels, multiple H1s, empty headings, duplicate headings, and a document that doesn't start at H1 |
353
354
  | `check_markdown_internal_links` | Scan a Markdown document's ATX headings, compute the GitHub-style anchor slug for each, and check every [text](#slug) internal link against them, flagging links that point at a heading that doesn't exist |
354
355
  | `summarize_csv_column_stats` | Parse CSV/TSV data and compute per-column summary statistics: null/unique counts for every column, plus min/max/mean/median/standard deviation for numeric columns or the most common value for categorical ones |
356
+ | `audit_markdown_image_alt_text` | Scan a Markdown document's inline images and flag alt text accessibility smells: missing/empty alt text, generic placeholder words, a redundant "image of"/"photo of" prefix, and alt text that's just the image's own filename |
355
357
 
356
358
  ### CSS (2 tools)
357
359
  | Tool | Description |
@@ -400,13 +402,14 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
400
402
  | `calculate_retry_backoff_schedule` | Compute the full sequence of delays an exponential-backoff retry policy would actually produce, given a base delay, multiplier, and max retry count, optionally capped at a max delay and with full/equal jitter ranges |
401
403
  | `extract_prompt_template_variables` | Extract every {{variable}}-style placeholder from a prompt template, report occurrence counts, flag duplicates, and optionally cross-check against a list of provided variable names |
402
404
 
403
- ### Image (4 tools)
405
+ ### Image (5 tools)
404
406
  | Tool | Description |
405
407
  |------|-------------|
406
408
  | `read_image_info` | Read format, dimensions, bit depth, and alpha channel from image bytes: no decoding required. Supports PNG, JPEG, GIF, WebP, BMP |
407
409
  | `read_exif_data` | Read camera make/model, orientation, timestamps, exposure settings, and GPS coordinates from a JPEG's EXIF data |
408
410
  | `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 |
409
411
  | `inspect_gif_frames` | Walk a GIF87a/89a file's block structure to report frame count, total animation duration, and loop count, without decoding any LZW-compressed pixel data |
412
+ | `read_png_chunks` | Walk a PNG's chunk stream directly from its bytes to report every chunk's type, offset, size, and CRC-32 validity, plus the decoded IHDR header and common ancillary chunks (tEXt, pHYs, gAMA, tIME, acTL) |
410
413
 
411
414
  ### PDF (3 tools)
412
415
  | Tool | Description |
@@ -415,13 +418,14 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
415
418
  | `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 |
416
419
  | `read_pdf_outline` | Walk a PDF's /Outlines bookmark tree (Root -> Outlines -> First/Next/First chain) and list every bookmark's title, nesting level, and link target, in document order: no PDF rendering library required |
417
420
 
418
- ### Audio (4 tools)
421
+ ### Audio (5 tools)
419
422
  | Tool | Description |
420
423
  |------|-------------|
421
424
  | `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 |
422
425
  | `read_id3_tags` | Read title, artist, album, year, genre, comment, and track number from an MP3's ID3 tags: no audio library required |
423
426
  | `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 |
424
427
  | `read_midi_info` | Parse a Standard MIDI File's header and every track chunk to report format, tick division, initial tempo, time signature, and per-track event/note-on counts, plus total duration in seconds: no MIDI playback or synthesis |
428
+ | `estimate_audio_file_size` | Estimate an audio file's size before recording or exporting it: uncompressed PCM size from sample rate/bit depth/channels, or compressed size from a target bitrate |
425
429
 
426
430
  ### Video (1 tool)
427
431
  | Tool | Description |
package/dist/index.js CHANGED
@@ -15090,5 +15090,322 @@ server.tool(
15090
15090
  return text(JSON.stringify(result, null, 2));
15091
15091
  }
15092
15092
  );
15093
+ var _PNG_SIGNATURE_MCP = [137, 80, 78, 71, 13, 10, 26, 10];
15094
+ function _readU32BEMcp(bytes, offset) {
15095
+ return (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
15096
+ }
15097
+ var _pngCrcTableMcp = null;
15098
+ function _pngCrc32Mcp(bytes) {
15099
+ if (!_pngCrcTableMcp) {
15100
+ const table = new Uint32Array(256);
15101
+ for (let n = 0; n < 256; n++) {
15102
+ let c = n;
15103
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
15104
+ table[n] = c;
15105
+ }
15106
+ _pngCrcTableMcp = table;
15107
+ }
15108
+ let crc = 4294967295;
15109
+ for (let i = 0; i < bytes.length; i++) crc = _pngCrcTableMcp[(crc ^ bytes[i]) & 255] ^ crc >>> 8;
15110
+ return (crc ^ 4294967295) >>> 0;
15111
+ }
15112
+ var _PNG_COLOR_TYPES_MCP = { 0: "grayscale", 2: "rgb", 3: "palette", 4: "grayscale+alpha", 6: "rgba" };
15113
+ function _pad2Mcp(n) {
15114
+ return n < 10 ? `0${n}` : `${n}`;
15115
+ }
15116
+ function _readPngChunksMcp(bytes) {
15117
+ if (bytes.length < 8) return { error: "File too small to be a valid PNG file" };
15118
+ for (let i = 0; i < 8; i++) {
15119
+ if (bytes[i] !== _PNG_SIGNATURE_MCP[i]) return { error: "Not a valid PNG file (bad 8-byte signature)" };
15120
+ }
15121
+ const chunks = [];
15122
+ const textChunks = [];
15123
+ let header = null;
15124
+ let physicalDimensions = null;
15125
+ let gamma = null;
15126
+ let lastModified = null;
15127
+ let isAnimated = false;
15128
+ let offset = 8;
15129
+ while (offset + 8 <= bytes.length) {
15130
+ const dataLength = _readU32BEMcp(bytes, offset);
15131
+ const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
15132
+ const dataStart = offset + 8;
15133
+ const dataEnd = dataStart + dataLength;
15134
+ const crcOffset = dataEnd;
15135
+ if (crcOffset + 4 > bytes.length) return { error: `Truncated chunk "${type}" at offset ${offset}` };
15136
+ const data = bytes.subarray(dataStart, dataEnd);
15137
+ const expectedCrc = _readU32BEMcp(bytes, crcOffset);
15138
+ const actualCrc = _pngCrc32Mcp(bytes.subarray(offset + 4, dataEnd));
15139
+ const firstChar = type.charCodeAt(0);
15140
+ const critical = firstChar >= 65 && firstChar <= 90;
15141
+ chunks.push({ type, offset, dataLength, crcValid: actualCrc === expectedCrc, critical });
15142
+ if (type === "IHDR" && data.length >= 13) {
15143
+ const colorTypeNum = data[9];
15144
+ header = {
15145
+ width: _readU32BEMcp(data, 0),
15146
+ height: _readU32BEMcp(data, 4),
15147
+ bitDepth: data[8],
15148
+ colorType: _PNG_COLOR_TYPES_MCP[colorTypeNum] ?? "unknown",
15149
+ hasAlpha: colorTypeNum === 4 || colorTypeNum === 6,
15150
+ interlace: data[12] === 1
15151
+ };
15152
+ } else if (type === "tEXt") {
15153
+ const nullIdx = data.indexOf(0);
15154
+ if (nullIdx !== -1) {
15155
+ textChunks.push({
15156
+ keyword: Buffer.from(data.subarray(0, nullIdx)).toString("latin1"),
15157
+ text: Buffer.from(data.subarray(nullIdx + 1)).toString("latin1"),
15158
+ compressed: false
15159
+ });
15160
+ }
15161
+ } else if (type === "zTXt" || type === "iTXt") {
15162
+ const nullIdx = data.indexOf(0);
15163
+ if (nullIdx !== -1) {
15164
+ textChunks.push({ keyword: Buffer.from(data.subarray(0, nullIdx)).toString("latin1"), text: null, compressed: true });
15165
+ }
15166
+ } else if (type === "pHYs" && data.length >= 9) {
15167
+ physicalDimensions = { pixelsPerUnitX: _readU32BEMcp(data, 0), pixelsPerUnitY: _readU32BEMcp(data, 4), unit: data[8] === 1 ? "meter" : "unknown" };
15168
+ } else if (type === "gAMA" && data.length >= 4) {
15169
+ gamma = _readU32BEMcp(data, 0) / 1e5;
15170
+ } else if (type === "tIME" && data.length >= 7) {
15171
+ const year = data[0] << 8 | data[1];
15172
+ lastModified = `${year}-${_pad2Mcp(data[2])}-${_pad2Mcp(data[3])}T${_pad2Mcp(data[4])}:${_pad2Mcp(data[5])}:${_pad2Mcp(data[6])}Z`;
15173
+ } else if (type === "acTL") {
15174
+ isAnimated = true;
15175
+ }
15176
+ offset = crcOffset + 4;
15177
+ if (type === "IEND") break;
15178
+ }
15179
+ if (chunks.length === 0) return { error: "No chunks found after the PNG signature" };
15180
+ if (!header) return { error: "Missing required IHDR chunk" };
15181
+ return { valid: true, fileSize: bytes.length, chunkCount: chunks.length, chunks, header, textChunks, physicalDimensions, gamma, lastModified, isAnimated };
15182
+ }
15183
+ server.tool(
15184
+ "read_png_chunks",
15185
+ "Walk a PNG file's chunk stream directly from its bytes to report every chunk's type, byte offset, data length, and whether its stored CRC-32 matches the data, plus the decoded IHDR header and a handful of common ancillary chunks (tEXt, pHYs, gAMA, tIME, acTL). No pixel decoding, and zTXt/iTXt text is listed but not zlib-inflated.",
15186
+ { png_base64: z.string().describe("Base64-encoded PNG file bytes, optionally prefixed with a data: URL scheme") },
15187
+ async ({ png_base64 }) => {
15188
+ const bytes = new Uint8Array(Buffer.from(png_base64.replace(/^data:.*;base64,/, ""), "base64"));
15189
+ const result = _readPngChunksMcp(bytes);
15190
+ if ("error" in result) return errText(result);
15191
+ return text(JSON.stringify(result, null, 2));
15192
+ }
15193
+ );
15194
+ function _estimateAudioFileSizeMcp(mode, durationSeconds, sampleRateHz, bitDepth, channels, bitrateKbps) {
15195
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
15196
+ return { error: "durationSeconds must be a positive number" };
15197
+ }
15198
+ const round2 = (n) => Math.round(n * 100) / 100;
15199
+ if (mode === "pcm") {
15200
+ if (typeof sampleRateHz !== "number" || !Number.isFinite(sampleRateHz) || sampleRateHz <= 0) {
15201
+ return { error: 'sampleRateHz must be a positive number for mode "pcm"' };
15202
+ }
15203
+ if (typeof bitDepth !== "number" || !Number.isFinite(bitDepth) || bitDepth <= 0) {
15204
+ return { error: 'bitDepth must be a positive number for mode "pcm"' };
15205
+ }
15206
+ if (typeof channels !== "number" || !Number.isInteger(channels) || channels <= 0) {
15207
+ return { error: 'channels must be a positive whole number for mode "pcm"' };
15208
+ }
15209
+ const dataBytes = sampleRateHz * (bitDepth / 8) * channels * durationSeconds;
15210
+ const fileSizeBytes = dataBytes + 44;
15211
+ return {
15212
+ mode,
15213
+ durationSeconds,
15214
+ sampleRateHz,
15215
+ bitDepth,
15216
+ channels,
15217
+ dataBytes: Math.round(dataBytes),
15218
+ fileSizeBytes: Math.round(fileSizeBytes),
15219
+ fileSizeMB: round2(fileSizeBytes / 1e6),
15220
+ fileSizeMiB: round2(fileSizeBytes / 1048576)
15221
+ };
15222
+ }
15223
+ if (mode === "compressed") {
15224
+ if (typeof bitrateKbps !== "number" || !Number.isFinite(bitrateKbps) || bitrateKbps <= 0) {
15225
+ return { error: 'bitrateKbps must be a positive number for mode "compressed"' };
15226
+ }
15227
+ const dataBytes = bitrateKbps * 1e3 * durationSeconds / 8;
15228
+ return {
15229
+ mode,
15230
+ durationSeconds,
15231
+ bitrateKbps,
15232
+ dataBytes: Math.round(dataBytes),
15233
+ fileSizeBytes: Math.round(dataBytes),
15234
+ fileSizeMB: round2(dataBytes / 1e6),
15235
+ fileSizeMiB: round2(dataBytes / 1048576)
15236
+ };
15237
+ }
15238
+ return { error: 'mode must be "pcm" or "compressed"' };
15239
+ }
15240
+ server.tool(
15241
+ "estimate_audio_file_size",
15242
+ 'Estimate how large an audio file will be before recording or exporting it. Mode "pcm" computes the uncompressed size from sample rate, bit depth, and channel count, plus a canonical 44-byte WAV header. Mode "compressed" computes it from a target bitrate. Pure arithmetic: no file is read, written, or encoded.',
15243
+ {
15244
+ mode: z.enum(["pcm", "compressed"]).describe('"pcm" requires sampleRateHz/bitDepth/channels; "compressed" requires bitrateKbps'),
15245
+ durationSeconds: z.number().describe("Audio duration in seconds"),
15246
+ sampleRateHz: z.number().optional().describe('Required for mode "pcm", e.g. 44100 for CD quality'),
15247
+ bitDepth: z.number().optional().describe('Required for mode "pcm", bits per sample, e.g. 16 or 24'),
15248
+ channels: z.number().optional().describe('Required for mode "pcm", e.g. 1 = mono, 2 = stereo'),
15249
+ bitrateKbps: z.number().optional().describe('Required for mode "compressed", e.g. 192 for a typical MP3')
15250
+ },
15251
+ async ({ mode, durationSeconds, sampleRateHz, bitDepth, channels, bitrateKbps }) => {
15252
+ const result = _estimateAudioFileSizeMcp(mode, durationSeconds, sampleRateHz, bitDepth, channels, bitrateKbps);
15253
+ if ("error" in result) return errText(result);
15254
+ return text(JSON.stringify(result, null, 2));
15255
+ }
15256
+ );
15257
+ var _ABA_FEDERAL_RESERVE_BANKS_MCP = {
15258
+ 1: "Boston",
15259
+ 2: "New York",
15260
+ 3: "Philadelphia",
15261
+ 4: "Cleveland",
15262
+ 5: "Richmond",
15263
+ 6: "Atlanta",
15264
+ 7: "Chicago",
15265
+ 8: "St. Louis",
15266
+ 9: "Minneapolis",
15267
+ 10: "Kansas City",
15268
+ 11: "Dallas",
15269
+ 12: "San Francisco"
15270
+ };
15271
+ function _abaDistrictAndTypeMcp(prefix) {
15272
+ if (prefix >= 1 && prefix <= 12) {
15273
+ return { type: "primary", district: { code: prefix, bank: _ABA_FEDERAL_RESERVE_BANKS_MCP[prefix] } };
15274
+ }
15275
+ if (prefix >= 21 && prefix <= 32) {
15276
+ const code = prefix - 20;
15277
+ return { type: "thrift", district: { code, bank: _ABA_FEDERAL_RESERVE_BANKS_MCP[code] } };
15278
+ }
15279
+ if (prefix >= 61 && prefix <= 72) {
15280
+ const code = prefix - 60;
15281
+ return { type: "electronic", district: { code, bank: _ABA_FEDERAL_RESERVE_BANKS_MCP[code] } };
15282
+ }
15283
+ if (prefix === 80) return { type: "traveler-check", district: null };
15284
+ return { type: "unknown", district: null };
15285
+ }
15286
+ function _formatRoutingNumberMcp(digits) {
15287
+ return `${digits.slice(0, 4)} ${digits.slice(4, 8)} ${digits.slice(8)}`;
15288
+ }
15289
+ function _validateRoutingNumberMcp(input) {
15290
+ if (!input || !input.trim()) return { error: "Input is empty" };
15291
+ const digits = input.replace(/[\s-]/g, "");
15292
+ if (!/^\d+$/.test(digits)) return { error: "Routing number must contain only digits, spaces, or hyphens" };
15293
+ if (digits.length !== 9) return { error: `Routing number length (${digits.length}) must be exactly 9 digits` };
15294
+ const d = digits.split("").map(Number);
15295
+ const sum = 3 * (d[0] + d[3] + d[6]) + 7 * (d[1] + d[4] + d[7]) + 1 * (d[2] + d[5] + d[8]);
15296
+ const checksumValid = sum % 10 === 0;
15297
+ const prefix = Number(digits.slice(0, 2));
15298
+ const { type, district } = _abaDistrictAndTypeMcp(prefix);
15299
+ return {
15300
+ valid: checksumValid,
15301
+ checksumValid,
15302
+ length: digits.length,
15303
+ formatted: _formatRoutingNumberMcp(digits),
15304
+ routingNumberType: type,
15305
+ federalReserveDistrict: district
15306
+ };
15307
+ }
15308
+ server.tool(
15309
+ "validate_routing_number",
15310
+ "Validate a 9-digit US bank routing number (ABA/RTN) via the weighted mod-10 checksum from the ABA's routing number specification. Also identifies the routing number's type and Federal Reserve district from its first two digits.",
15311
+ { routingNumber: z.string().describe("The 9-digit routing number, with optional spaces or hyphens") },
15312
+ async ({ routingNumber }) => {
15313
+ const result = _validateRoutingNumberMcp(routingNumber);
15314
+ if ("error" in result) return errText(result);
15315
+ return text(JSON.stringify(result, null, 2));
15316
+ }
15317
+ );
15318
+ var _MARKDOWN_IMAGE_RE_MCP = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
15319
+ var _MARKDOWN_IMAGE_GENERIC_ALT_WORDS_MCP = /* @__PURE__ */ new Set([
15320
+ "image",
15321
+ "picture",
15322
+ "photo",
15323
+ "photograph",
15324
+ "pic",
15325
+ "img",
15326
+ "graphic",
15327
+ "screenshot",
15328
+ "icon",
15329
+ "logo"
15330
+ ]);
15331
+ var _MARKDOWN_IMAGE_REDUNDANT_PREFIX_RE_MCP = /^(image|picture|photo|photograph)\s+of\s+/i;
15332
+ var _MARKDOWN_IMAGE_FILENAME_EXT_RE_MCP = /\.(png|jpe?g|gif|svg|webp|bmp|avif|tiff?)$/i;
15333
+ function _markdownImageBasenameWithoutExtMcp(src) {
15334
+ const withoutQuery = src.split(/[?#]/)[0];
15335
+ const file = withoutQuery.split("/").pop() ?? withoutQuery;
15336
+ return file.replace(_MARKDOWN_IMAGE_FILENAME_EXT_RE_MCP, "");
15337
+ }
15338
+ function _auditMarkdownImageAltTextMcp(markdown) {
15339
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
15340
+ const images = [];
15341
+ const issues = [];
15342
+ let inFence = false;
15343
+ let fenceMarker = "";
15344
+ lines.forEach((line, idx) => {
15345
+ const fenceMatch = line.match(/^\s*(```+|~~~+)/);
15346
+ if (fenceMatch) {
15347
+ if (!inFence) {
15348
+ inFence = true;
15349
+ fenceMarker = fenceMatch[1][0];
15350
+ } else if (fenceMatch[1][0] === fenceMarker) {
15351
+ inFence = false;
15352
+ }
15353
+ return;
15354
+ }
15355
+ if (inFence) return;
15356
+ const lineNo = idx + 1;
15357
+ _MARKDOWN_IMAGE_RE_MCP.lastIndex = 0;
15358
+ let match;
15359
+ while ((match = _MARKDOWN_IMAGE_RE_MCP.exec(line)) !== null) {
15360
+ const alt = match[1].trim();
15361
+ const src = match[2];
15362
+ images.push({ line: lineNo, alt, src });
15363
+ if (alt === "") {
15364
+ issues.push({
15365
+ line: lineNo,
15366
+ type: "empty-alt",
15367
+ src,
15368
+ message: `Image at line ${lineNo} has no alt text (src: ${src}). If this image is purely decorative, an intentionally empty alt is fine; otherwise it needs a description.`
15369
+ });
15370
+ continue;
15371
+ }
15372
+ if (_MARKDOWN_IMAGE_GENERIC_ALT_WORDS_MCP.has(alt.toLowerCase())) {
15373
+ issues.push({
15374
+ line: lineNo,
15375
+ type: "generic-alt",
15376
+ src,
15377
+ message: `Alt text "${alt}" at line ${lineNo} is a generic placeholder and doesn't describe the image's content.`
15378
+ });
15379
+ }
15380
+ if (_MARKDOWN_IMAGE_REDUNDANT_PREFIX_RE_MCP.test(alt)) {
15381
+ issues.push({
15382
+ line: lineNo,
15383
+ type: "redundant-prefix",
15384
+ src,
15385
+ message: `Alt text "${alt}" at line ${lineNo} starts with a redundant prefix; screen readers already announce images, so "${alt.replace(_MARKDOWN_IMAGE_REDUNDANT_PREFIX_RE_MCP, "")}" reads better.`
15386
+ });
15387
+ }
15388
+ const baseName = _markdownImageBasenameWithoutExtMcp(src);
15389
+ if (_MARKDOWN_IMAGE_FILENAME_EXT_RE_MCP.test(alt) || baseName !== "" && alt.toLowerCase() === baseName.toLowerCase()) {
15390
+ issues.push({
15391
+ line: lineNo,
15392
+ type: "filename-as-alt",
15393
+ src,
15394
+ message: `Alt text "${alt}" at line ${lineNo} looks like the image's own filename rather than a description.`
15395
+ });
15396
+ }
15397
+ }
15398
+ });
15399
+ return { images, issues, imageCount: images.length };
15400
+ }
15401
+ server.tool(
15402
+ "audit_markdown_image_alt_text",
15403
+ 'Scan a Markdown document\'s inline images (`![alt](src)`) and flag alt text accessibility smells: missing/empty alt text, a generic placeholder word used alone ("image", "photo"), alt text that\'s just the image\'s own filename, and a redundant "image of"/"photo of" prefix. Only inline image syntax is scanned; reference-style images and raw HTML `<img>` tags are not detected.',
15404
+ { markdown: z.string().describe("The Markdown document to scan") },
15405
+ async ({ markdown }) => {
15406
+ const result = _auditMarkdownImageAltTextMcp(markdown);
15407
+ return text(JSON.stringify(result, null, 2));
15408
+ }
15409
+ );
15093
15410
  var transport = new StdioServerTransport();
15094
15411
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.94.0",
4
- "description": "MCP server exposing 279 Utilix developer tools to Claude, Cursor, VS Code Copilot, and any MCP-compatible AI assistant.",
3
+ "version": "0.96.0",
4
+ "description": "MCP server exposing 285 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",