@utilix-tech/mcp 0.53.0 → 0.55.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.55.0
6
+ - Add `validate_package_exports` tool: parse a package.json's exports field and flag common dual-package (ESM/CJS) misconfigurations, a types condition not listed first, a missing default fallback, mismatched require/import extensions, mixed subpath/condition keys, and pattern mismatches.
7
+ - Add `build_x_content_type_options_header` tool: build the fixed X-Content-Type-Options: nosniff header, validate a pasted value, and assess MIME-sniffing risk for a given Content-Type.
8
+
9
+ ## 0.54.0
10
+ - Add `calculate_credit_utilization` tool: compute per-account and overall credit utilization (balance / limit) from one or more credit accounts, plus a qualitative tier matching common credit-scoring guidance.
11
+ - Add `analyze_caption_speed` tool: parse an SRT or WebVTT file and report the words-per-minute reading speed of every cue, flagging ones that exceed common broadcast accessibility guidelines.
12
+
5
13
  ## 0.53.0
6
14
  - Add `validate_editorconfig` tool: parse an .editorconfig file into its root flag and glob sections, and flag structural mistakes against the EditorConfig spec.
7
15
  - Add `validate_tsconfig` tool: parse a tsconfig.json file as JSONC and flag unknown keys, unknown compilerOptions, and type mismatches against a curated list of known options.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 194 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
3
+ MCP server exposing 198 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
 
@@ -160,7 +160,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
160
160
  | `convert_timezone` | Convert a date/time between timezones |
161
161
  | `convert_iso8601_duration` | Parse an ISO 8601 duration into components and total seconds, or format seconds into a duration string |
162
162
 
163
- ### Units (32 tools)
163
+ ### Units (33 tools)
164
164
  | Tool | Description |
165
165
  |------|-------------|
166
166
  | `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
@@ -195,8 +195,9 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
195
195
  | `calculate_cash_rounding` | Round a cashless total to the nearest payable cash denomination ("Swedish rounding"), with round-nearest, round-up, and round-down modes |
196
196
  | `calculate_moving_averages` | Compute simple moving average (SMA) and exponential moving average (EMA) series from a list of numbers and a window size |
197
197
  | `calculate_gpa` | Compute a weighted GPA from a list of courses, each with a letter grade and credit hours, using the standard 4.0-capped scale |
198
+ | `calculate_credit_utilization` | Compute per-account and overall credit utilization (balance / limit) from one or more credit accounts, plus a qualitative tier matching common credit-scoring guidance |
198
199
 
199
- ### API / Network (28 tools)
200
+ ### API / Network (29 tools)
200
201
  | Tool | Description |
201
202
  |------|-------------|
202
203
  | `decode_jwt` | Decode a JWT: header, payload, and expiry |
@@ -227,6 +228,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
227
228
  | `parse_client_hint_headers` | Decode raw Client Hints request-header text into structured values: brand/version pairs, booleans, strings, and numbers |
228
229
  | `build_content_disposition` | Build a Content-Disposition response header for a file download or inline display, with an RFC 5987 filename* parameter for non-ASCII filenames |
229
230
  | `parse_content_disposition` | Parse a Content-Disposition header value into its disposition type, decoded filename, and raw parameters |
231
+ | `build_x_content_type_options_header` | Build the fixed X-Content-Type-Options: nosniff header, validate a pasted value, or assess MIME-sniffing risk for a given Content-Type |
230
232
 
231
233
  ### Color (4 tools)
232
234
  | Tool | Description |
@@ -236,7 +238,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
236
238
  | `color_palette` | Generate a color palette from a base color |
237
239
  | `color_shades` | Generate shades or tints from a base color |
238
240
 
239
- ### Code (14 tools)
241
+ ### Code (15 tools)
240
242
  | Tool | Description |
241
243
  |------|-------------|
242
244
  | `format_sql` | Format a SQL query with proper indentation |
@@ -253,8 +255,9 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
253
255
  | `test_gitignore_patterns` | Test a list of paths against a .gitignore file's patterns, following git's last-match-wins and negation rules |
254
256
  | `validate_editorconfig` | Validate an .editorconfig file: root flag, glob sections, and structural mistakes against the EditorConfig spec |
255
257
  | `validate_tsconfig` | Validate a tsconfig.json file (JSONC-aware): unknown keys, unknown compilerOptions, and type mismatches |
258
+ | `validate_package_exports` | Validate a package.json "exports" field: dual-package (ESM/CJS) misconfigurations like a missing default fallback or a require/import pointing at the wrong extension |
256
259
 
257
- ### Data (12 tools)
260
+ ### Data (13 tools)
258
261
  | Tool | Description |
259
262
  |------|-------------|
260
263
  | `validate_yaml` | Validate YAML syntax |
@@ -269,6 +272,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
269
272
  | `csv_to_sql_insert` | Convert CSV data with a header row into parameterless SQL INSERT statements, with basic type inference and ANSI/MySQL identifier quoting |
270
273
  | `find_csv_duplicate_rows` | Group CSV/TSV rows that share the same value across a chosen set of key columns, or the entire row |
271
274
  | `calculate_markdown_task_list_progress` | Parse a Markdown document's checkbox task list and report completion percentage overall and per section, grouped by the nearest preceding heading |
275
+ | `analyze_caption_speed` | Parse an SRT or WebVTT file and report the words-per-minute reading speed of every cue, flagging ones that exceed common broadcast accessibility guidelines |
272
276
 
273
277
  ### CSS (2 tools)
274
278
  | Tool | Description |
package/dist/index.js CHANGED
@@ -8864,5 +8864,350 @@ server.tool(
8864
8864
  return text(JSON.stringify(result, null, 2));
8865
8865
  }
8866
8866
  );
8867
+ function _classifyUtilizationMcp(percent) {
8868
+ if (percent < 10) return "excellent";
8869
+ if (percent < 30) return "good";
8870
+ if (percent < 50) return "fair";
8871
+ return "poor";
8872
+ }
8873
+ function _calculateCreditUtilizationMcp(accounts) {
8874
+ if (!Array.isArray(accounts) || accounts.length === 0) {
8875
+ return { error: "accounts must be a non-empty array" };
8876
+ }
8877
+ const round2 = (n) => Math.round(n * 100) / 100;
8878
+ let totalBalance = 0;
8879
+ let totalLimit = 0;
8880
+ const results = [];
8881
+ for (let i = 0; i < accounts.length; i++) {
8882
+ const account = accounts[i];
8883
+ if (typeof account.balance !== "number" || !Number.isFinite(account.balance) || account.balance < 0) {
8884
+ return { error: `account ${i + 1}: balance must be a finite, non-negative number` };
8885
+ }
8886
+ if (typeof account.limit !== "number" || !Number.isFinite(account.limit) || account.limit <= 0) {
8887
+ return { error: `account ${i + 1}: limit must be a finite number greater than zero` };
8888
+ }
8889
+ const utilizationPercent = round2(account.balance / account.limit * 100);
8890
+ totalBalance += account.balance;
8891
+ totalLimit += account.limit;
8892
+ results.push({
8893
+ label: account.label?.trim() || `Account ${i + 1}`,
8894
+ balance: account.balance,
8895
+ limit: account.limit,
8896
+ utilizationPercent,
8897
+ tier: _classifyUtilizationMcp(utilizationPercent)
8898
+ });
8899
+ }
8900
+ const overallUtilizationPercent = round2(totalBalance / totalLimit * 100);
8901
+ return {
8902
+ accounts: results,
8903
+ totalBalance: round2(totalBalance),
8904
+ totalLimit: round2(totalLimit),
8905
+ overallUtilizationPercent,
8906
+ overallTier: _classifyUtilizationMcp(overallUtilizationPercent)
8907
+ };
8908
+ }
8909
+ server.tool(
8910
+ "calculate_credit_utilization",
8911
+ "Compute per-account and overall credit utilization (balance / limit) from one or more credit accounts, plus a qualitative tier matching common credit-scoring guidance: under 10% is excellent, under 30% is good (the generally recommended ceiling), under 50% is fair, and 50%+ is poor. Overall utilization is total balance over total limit, not an average of the per-account percentages.",
8912
+ {
8913
+ accounts: z.array(z.object({
8914
+ label: z.string().optional().describe("Optional account label"),
8915
+ balance: z.number().min(0).describe("Current balance on the account"),
8916
+ limit: z.number().positive().describe("Credit limit on the account")
8917
+ })).min(1).describe("A non-empty list of credit accounts")
8918
+ },
8919
+ async ({ accounts }) => {
8920
+ const result = _calculateCreditUtilizationMcp(accounts);
8921
+ if ("error" in result) return errText(result);
8922
+ return text(JSON.stringify(result, null, 2));
8923
+ }
8924
+ );
8925
+ var _CAPTION_TIMESTAMP_RE_MCP = /(\d+):(\d{1,2}):(\d{1,2})[.,](\d{1,3})|(\d{1,3}):(\d{1,2})[.,](\d{1,3})/;
8926
+ var _CAPTION_SKIP_BLOCK_KEYWORDS_MCP = ["NOTE", "STYLE", "REGION"];
8927
+ function _parseCaptionTimestampMcp(raw) {
8928
+ const m = _CAPTION_TIMESTAMP_RE_MCP.exec(raw.trim());
8929
+ if (!m || m.index !== 0) return null;
8930
+ let hours, minutes, seconds, millis;
8931
+ if (m[1] !== void 0) {
8932
+ hours = Number(m[1]);
8933
+ minutes = Number(m[2]);
8934
+ seconds = Number(m[3]);
8935
+ millis = m[4];
8936
+ } else {
8937
+ hours = 0;
8938
+ minutes = Number(m[5]);
8939
+ seconds = Number(m[6]);
8940
+ millis = m[7];
8941
+ }
8942
+ if (minutes > 59 || seconds > 59) return null;
8943
+ return hours * 3600 + minutes * 60 + seconds + Number(millis.padEnd(3, "0")) / 1e3;
8944
+ }
8945
+ function _extractCaptionCuesMcp(input) {
8946
+ const stripped = input.charCodeAt(0) === 65279 ? input.slice(1) : input;
8947
+ let body = stripped.replace(/\r\n?/g, "\n");
8948
+ const headerMatch = /^\s*WEBVTT[^\n]*\n?/.exec(body);
8949
+ if (headerMatch) body = body.slice(headerMatch[0].length);
8950
+ const blocks = body.split(/\n[ \t]*\n+/).map((b) => b.trim()).filter((b) => b.length > 0);
8951
+ const cues = [];
8952
+ for (const block of blocks) {
8953
+ const lines = block.split("\n");
8954
+ const arrowIdx = lines.findIndex((l) => l.includes("-->"));
8955
+ if (arrowIdx === -1) continue;
8956
+ if (_CAPTION_SKIP_BLOCK_KEYWORDS_MCP.some((k) => lines[0] === k || lines[0].startsWith(`${k} `))) continue;
8957
+ const [leftRaw, rightRaw] = lines[arrowIdx].split("-->");
8958
+ const start = _parseCaptionTimestampMcp(leftRaw ?? "");
8959
+ const end = _parseCaptionTimestampMcp((rightRaw ?? "").trim());
8960
+ if (start === null || end === null || end <= start) continue;
8961
+ const text2 = lines.slice(arrowIdx + 1).join(" ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
8962
+ if (text2.length === 0) continue;
8963
+ cues.push({ start, end, text: text2 });
8964
+ }
8965
+ return cues;
8966
+ }
8967
+ function _analyzeCaptionSpeedMcp(input, wpmThreshold, cpsThreshold) {
8968
+ if (typeof input !== "string" || input.trim().length === 0) return { error: "Subtitle input is empty" };
8969
+ if (!Number.isFinite(wpmThreshold) || wpmThreshold <= 0) {
8970
+ return { error: "wpmThreshold must be a finite number greater than zero" };
8971
+ }
8972
+ if (!Number.isFinite(cpsThreshold) || cpsThreshold <= 0) {
8973
+ return { error: "cpsThreshold must be a finite number greater than zero" };
8974
+ }
8975
+ const rawCues = _extractCaptionCuesMcp(input);
8976
+ if (rawCues.length === 0) return { error: "No subtitle cues with timing and text were found" };
8977
+ const round1 = (n) => Math.round(n * 10) / 10;
8978
+ const cues = rawCues.map((cue, i) => {
8979
+ const durationSeconds = cue.end - cue.start;
8980
+ const wordCount = cue.text.split(" ").filter(Boolean).length;
8981
+ const charCount = cue.text.length;
8982
+ const wpm = wordCount / durationSeconds * 60;
8983
+ const charsPerSecond = charCount / durationSeconds;
8984
+ return {
8985
+ index: i + 1,
8986
+ start: round1(cue.start),
8987
+ end: round1(cue.end),
8988
+ text: cue.text,
8989
+ durationSeconds: round1(durationSeconds),
8990
+ wordCount,
8991
+ charCount,
8992
+ wpm: round1(wpm),
8993
+ charsPerSecond: round1(charsPerSecond),
8994
+ exceedsWpm: wpm > wpmThreshold,
8995
+ exceedsCps: charsPerSecond > cpsThreshold
8996
+ };
8997
+ });
8998
+ const wpmValues = cues.map((c) => c.wpm);
8999
+ return {
9000
+ cueCount: cues.length,
9001
+ cues,
9002
+ averageWpm: round1(wpmValues.reduce((a, b) => a + b, 0) / wpmValues.length),
9003
+ maxWpm: round1(Math.max(...wpmValues)),
9004
+ flaggedCount: cues.filter((c) => c.exceedsWpm || c.exceedsCps).length,
9005
+ wpmThreshold,
9006
+ cpsThreshold
9007
+ };
9008
+ }
9009
+ server.tool(
9010
+ "analyze_caption_speed",
9011
+ "Parse an SRT or WebVTT file and report the words-per-minute reading speed implied by each cue's word count and duration, flagging cues that exceed common broadcast accessibility guidelines (default: ~180 WPM, ~20 characters/second). HTML-style cue tags like <i> are stripped before counting words and characters.",
9012
+ {
9013
+ input: z.string().describe("The SRT or WebVTT file contents"),
9014
+ wpmThreshold: z.number().positive().optional().describe("Words-per-minute above which a cue is flagged (default 180)"),
9015
+ cpsThreshold: z.number().positive().optional().describe("Characters-per-second above which a cue is flagged (default 20)")
9016
+ },
9017
+ async ({ input, wpmThreshold, cpsThreshold }) => {
9018
+ const result = _analyzeCaptionSpeedMcp(input, wpmThreshold ?? 180, cpsThreshold ?? 20);
9019
+ if ("error" in result) return errText(result);
9020
+ return text(JSON.stringify(result, null, 2));
9021
+ }
9022
+ );
9023
+ function _collectExportsStringLeavesMcp(value, out = []) {
9024
+ if (typeof value === "string") out.push(value);
9025
+ else if (Array.isArray(value)) value.forEach((v) => _collectExportsStringLeavesMcp(v, out));
9026
+ else if (value && typeof value === "object") Object.values(value).forEach((v) => _collectExportsStringLeavesMcp(v, out));
9027
+ return out;
9028
+ }
9029
+ function _checkExportsPatternMatchMcp(key, value, path, issues) {
9030
+ const keyHasStar = key.includes("*");
9031
+ const leaves = _collectExportsStringLeavesMcp(value);
9032
+ if (keyHasStar) {
9033
+ const bad = leaves.filter((l) => !l.includes("*"));
9034
+ if (bad.length > 0) {
9035
+ issues.push({ severity: "error", path, message: `pattern key "${key}" contains "*" but target ${bad.map((l) => `"${l}"`).join(", ")} has no "*" to receive the matched text` });
9036
+ }
9037
+ } else {
9038
+ const bad = leaves.filter((l) => l.includes("*"));
9039
+ if (bad.length > 0) {
9040
+ issues.push({ severity: "error", path, message: `target ${bad.map((l) => `"${l}"`).join(", ")} contains "*" but key "${key}" has no "*" pattern to match against` });
9041
+ }
9042
+ }
9043
+ }
9044
+ function _validateExportsValueMcp(value, path, issues) {
9045
+ if (value === null) return;
9046
+ if (typeof value === "string") {
9047
+ if (!value.startsWith("./")) {
9048
+ issues.push({ severity: "error", path, message: `target "${value}" must start with "./"` });
9049
+ }
9050
+ return;
9051
+ }
9052
+ if (Array.isArray(value)) {
9053
+ if (value.length === 0) {
9054
+ issues.push({ severity: "warning", path, message: "empty fallback array will never resolve" });
9055
+ }
9056
+ value.forEach((v, i) => _validateExportsValueMcp(v, `${path}[${i}]`, issues));
9057
+ return;
9058
+ }
9059
+ if (typeof value === "object") {
9060
+ const obj = value;
9061
+ const keys = Object.keys(obj);
9062
+ if (keys.length === 0) {
9063
+ issues.push({ severity: "warning", path, message: "empty object will never resolve" });
9064
+ return;
9065
+ }
9066
+ const hasDotKeys = keys.some((k) => k.startsWith("."));
9067
+ const hasNonDotKeys = keys.some((k) => !k.startsWith("."));
9068
+ if (hasDotKeys && hasNonDotKeys) {
9069
+ issues.push({ severity: "error", path, message: 'cannot mix subpath keys (starting with ".") with condition keys in the same object' });
9070
+ return;
9071
+ }
9072
+ if (hasDotKeys) {
9073
+ for (const key of keys) {
9074
+ const subpath = `${path}["${key}"]`;
9075
+ if (key !== "." && !key.startsWith("./")) {
9076
+ issues.push({ severity: "error", path: subpath, message: `subpath key "${key}" must be "." or start with "./"` });
9077
+ }
9078
+ _checkExportsPatternMatchMcp(key, obj[key], subpath, issues);
9079
+ _validateExportsValueMcp(obj[key], subpath, issues);
9080
+ }
9081
+ return;
9082
+ }
9083
+ if (keys.includes("types") && keys[0] !== "types") {
9084
+ issues.push({ severity: "warning", path, message: '"types" condition should be listed first so TypeScript resolves it before any other condition matches' });
9085
+ }
9086
+ if (keys.includes("default") && keys[keys.length - 1] !== "default") {
9087
+ issues.push({ severity: "warning", path, message: '"default" condition should be listed last; conditions are matched in order and anything after "default" is unreachable' });
9088
+ }
9089
+ if ((keys.includes("import") || keys.includes("require")) && !keys.includes("default")) {
9090
+ issues.push({ severity: "warning", path, message: 'no "default" fallback condition; resolution fails for any importer using a condition not listed here' });
9091
+ }
9092
+ const requireVal = obj.require;
9093
+ if (typeof requireVal === "string" && requireVal.endsWith(".mjs")) {
9094
+ issues.push({ severity: "error", path: `${path}.require`, message: `"require" condition points to a .mjs file ("${requireVal}"), but .mjs is always treated as ESM and cannot be required` });
9095
+ }
9096
+ const importVal = obj.import;
9097
+ if (typeof importVal === "string" && importVal.endsWith(".cjs")) {
9098
+ issues.push({ severity: "error", path: `${path}.import`, message: `"import" condition points to a .cjs file ("${importVal}"), but .cjs is always treated as CommonJS and cannot be imported as ESM` });
9099
+ }
9100
+ for (const key of keys) {
9101
+ _validateExportsValueMcp(obj[key], `${path}.${key}`, issues);
9102
+ }
9103
+ return;
9104
+ }
9105
+ issues.push({ severity: "error", path, message: `unexpected value type at "${path}"` });
9106
+ }
9107
+ function _validatePackageExportsMcp(packageJsonText) {
9108
+ if (!packageJsonText || !packageJsonText.trim()) return { error: "Input is empty" };
9109
+ let parsed;
9110
+ try {
9111
+ parsed = JSON.parse(packageJsonText);
9112
+ } catch (e) {
9113
+ return { error: `Invalid JSON: ${e.message}` };
9114
+ }
9115
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
9116
+ return { error: "Root of package.json must be an object" };
9117
+ }
9118
+ const obj = parsed;
9119
+ if (!("exports" in obj)) return { error: 'package.json has no "exports" field' };
9120
+ const exportsValue = obj.exports;
9121
+ const issues = [];
9122
+ let exportsShape;
9123
+ if (exportsValue === null) {
9124
+ exportsShape = "null";
9125
+ } else if (typeof exportsValue === "string") {
9126
+ exportsShape = "string";
9127
+ } else if (Array.isArray(exportsValue)) {
9128
+ exportsShape = "array";
9129
+ } else if (typeof exportsValue === "object") {
9130
+ const keys = Object.keys(exportsValue);
9131
+ exportsShape = keys.some((k) => k.startsWith(".")) ? "subpaths" : "conditions";
9132
+ } else {
9133
+ return { error: '"exports" must be a string, array, object, or null' };
9134
+ }
9135
+ _validateExportsValueMcp(exportsValue, "exports", issues);
9136
+ const subpaths = exportsShape === "subpaths" ? Object.keys(exportsValue) : ["."];
9137
+ const valid = !issues.some((i) => i.severity === "error");
9138
+ return { parsed: true, exportsShape, subpaths, issues, valid };
9139
+ }
9140
+ server.tool(
9141
+ "validate_package_exports",
9142
+ 'Validate a package.json "exports" field: flag common dual-package (ESM/CJS) misconfigurations \u2014 a "types" condition not listed first, a missing "default" fallback, a "require"/"import" pointing at the wrong file extension, mixing subpath and condition keys in the same object, and a subpath pattern with no matching "*" in its target.',
9143
+ {
9144
+ text: z.string().describe('The full package.json file contents (only the "exports" field is read)')
9145
+ },
9146
+ async ({ text: packageJsonText }) => {
9147
+ const result = _validatePackageExportsMcp(packageJsonText);
9148
+ if ("error" in result) return errText(result);
9149
+ return text(JSON.stringify(result, null, 2));
9150
+ }
9151
+ );
9152
+ var _X_CONTENT_TYPE_OPTIONS_HEADER_MCP = "X-Content-Type-Options: nosniff";
9153
+ var _X_CONTENT_TYPE_EXACT_RISK_TABLE_MCP = {
9154
+ "text/plain": { risk: "high", reason: "Browsers historically sniff text/plain responses and may render them as HTML if the body looks like markup, letting user-controlled text (comments, error pages, logs) execute as script." },
9155
+ "application/octet-stream": { risk: "high", reason: "A generic fallback type with no format hint, so browsers are the most likely to sniff its real content and may render it as HTML." },
9156
+ "": { risk: "high", reason: "A missing Content-Type header gives the browser nothing to trust, so it falls back to sniffing the response body entirely." }
9157
+ };
9158
+ var _X_CONTENT_TYPE_PREFIX_RISK_TABLE_MCP = [
9159
+ { prefix: "text/html", risk: "medium", reason: "Already interpreted as HTML, so sniffing mostly matters if the type is wrong on purpose; nosniff still protects against a mismatched charset or embedded polyglot content." },
9160
+ { prefix: "image/", risk: "low", reason: 'Browsers rarely upgrade an image response to HTML, but a crafted "GIFAR"-style polyglot file can still be sniffed as HTML in older engines.' },
9161
+ { prefix: "audio/", risk: "low", reason: "Media types are rarely reinterpreted as HTML by modern sniffing algorithms." },
9162
+ { prefix: "video/", risk: "low", reason: "Media types are rarely reinterpreted as HTML by modern sniffing algorithms." },
9163
+ { prefix: "font/", risk: "low", reason: "Font types are rarely reinterpreted as HTML by modern sniffing algorithms." },
9164
+ { prefix: "application/pdf", risk: "low", reason: "PDF has a strong magic-number signature, so sniffing rarely reclassifies it, though nosniff is still worth setting for user-uploaded PDFs." },
9165
+ { prefix: "application/json", risk: "medium", reason: "Structurally distinct from HTML, but user-controlled JSONP-style responses served with a wrong or missing type have been used in real sniffing attacks." },
9166
+ { prefix: "application/javascript", risk: "medium", reason: "Already executable; nosniff mainly protects against it being served where a script tag was not expected, such as inside an <img> or <object> tag." },
9167
+ { prefix: "application/xml", risk: "medium", reason: "XML can be reinterpreted in ways that overlap with HTML/XHTML parsing in some legacy sniffing paths." }
9168
+ ];
9169
+ function _assessContentTypeRiskMcp(contentType) {
9170
+ const normalized = contentType.trim().toLowerCase().split(";")[0].trim();
9171
+ if (normalized in _X_CONTENT_TYPE_EXACT_RISK_TABLE_MCP) {
9172
+ const entry = _X_CONTENT_TYPE_EXACT_RISK_TABLE_MCP[normalized];
9173
+ return { contentType: contentType.trim() || "(empty)", risk: entry.risk, reason: entry.reason };
9174
+ }
9175
+ const prefixMatch = _X_CONTENT_TYPE_PREFIX_RISK_TABLE_MCP.find((e) => normalized.startsWith(e.prefix));
9176
+ if (prefixMatch) {
9177
+ return { contentType: contentType.trim(), risk: prefixMatch.risk, reason: prefixMatch.reason };
9178
+ }
9179
+ return {
9180
+ contentType: contentType.trim(),
9181
+ risk: "medium",
9182
+ reason: "Not a well-known type in this tool's lookup table. Treat any response type as sniffable unless you've confirmed the serving browser trusts it, and set nosniff regardless of type as a baseline defense."
9183
+ };
9184
+ }
9185
+ server.tool(
9186
+ "build_x_content_type_options_header",
9187
+ 'Build the X-Content-Type-Options header (only one valid value, "nosniff") with a plain-English explanation of the MIME-sniffing attack it prevents, optionally validating a pasted header value or assessing MIME-sniffing risk for a given Content-Type.',
9188
+ {
9189
+ headerValue: z.string().optional().describe('A pasted header value or full header line to validate, e.g. "nosniff"'),
9190
+ contentType: z.string().optional().describe('A Content-Type value to assess MIME-sniffing risk for, e.g. "text/plain"')
9191
+ },
9192
+ async ({ headerValue, contentType }) => {
9193
+ if (typeof headerValue === "string") {
9194
+ const withoutLabel = headerValue.trim().replace(/^x-content-type-options\s*:\s*/i, "");
9195
+ const value = withoutLabel.trim();
9196
+ if (value === "") return errText({ error: 'No value found after "X-Content-Type-Options:"' });
9197
+ if (value.toLowerCase() === "nosniff") {
9198
+ return text(JSON.stringify({ raw: value, valid: true, message: "Valid. The browser will not MIME-sniff this response." }, null, 2));
9199
+ }
9200
+ return text(JSON.stringify({ raw: value, valid: false, message: `"${value}" is not a recognized value. The only value browsers act on is "nosniff" (case-insensitive); anything else is silently ignored, same as omitting the header.` }, null, 2));
9201
+ }
9202
+ if (typeof contentType === "string") {
9203
+ return text(JSON.stringify(_assessContentTypeRiskMcp(contentType), null, 2));
9204
+ }
9205
+ return text(JSON.stringify({
9206
+ header: _X_CONTENT_TYPE_OPTIONS_HEADER_MCP,
9207
+ value: "nosniff",
9208
+ explanation: `Tells the browser to strictly honor the declared Content-Type header instead of "sniffing" a response's bytes to guess its real type. Without it, a browser that decides an oddly-typed response looks like HTML or JavaScript may render or execute it, letting a file that was only ever meant to be downloaded or displayed as plain text be used to run a cross-site scripting attack.`
9209
+ }, null, 2));
9210
+ }
9211
+ );
8867
9212
  var transport = new StdioServerTransport();
8868
9213
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.53.0",
4
- "description": "MCP server exposing 188 Utilix developer tools to Claude, Cursor, VS Code Copilot, and any MCP-compatible AI assistant.",
3
+ "version": "0.55.0",
4
+ "description": "MCP server exposing 198 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",