@utilix-tech/mcp 0.58.0 → 0.60.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.60.0
6
+ - Add `build_retry_after_header`, `parse_retry_after_header` tools: build an HTTP Retry-After response header from a delay in seconds or a target date, or parse an existing one, per RFC 9110.
7
+ - Add `calculate_freelancer_rate` tool: solve for the minimum hourly rate a freelancer must charge to hit a target annual income, given billable hours, weeks worked, overhead, and a profit margin.
8
+
9
+ ## 0.59.0
10
+ - Add `build_prefer_header`, `parse_prefer_header` tools: build an HTTP Prefer request-header value from preferences with optional values and parameters per RFC 7240, or parse an existing one into the same shape.
11
+ - Add `dedupe_ndjson` tool: remove duplicate records from NDJSON input, either by exact structural equality or by a chosen key field, keeping the first occurrence of each duplicate.
12
+
5
13
  ## 0.58.0
6
14
  - Add `apply_json_merge_patch` tool: apply an RFC 7396 JSON Merge Patch document to a target JSON document, with null-valued patch keys removing the matching target key.
7
15
  - Add `generate_csp_nonce` tool: generate a cryptographically random, base64-encoded Content-Security-Policy nonce with ready-to-paste header directive and script/style tag snippets.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 205 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
3
+ MCP server exposing 211 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
 
@@ -162,7 +162,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
162
162
  | `convert_timezone` | Convert a date/time between timezones |
163
163
  | `convert_iso8601_duration` | Parse an ISO 8601 duration into components and total seconds, or format seconds into a duration string |
164
164
 
165
- ### Units (35 tools)
165
+ ### Units (36 tools)
166
166
  | Tool | Description |
167
167
  |------|-------------|
168
168
  | `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
@@ -200,8 +200,9 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
200
200
  | `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 |
201
201
  | `calculate_debt_to_income_ratio` | Compute the debt-to-income ratio (total monthly debt payments / gross monthly income) from one or more monthly debts, plus a qualitative tier matching common mortgage-lending guidance |
202
202
  | `calculate_blended_loan_rate` | Compute the single weighted-average interest rate across a list of loan balances, weighted by each loan's share of the combined balance, plus combined monthly and annual interest accrual |
203
+ | `calculate_freelancer_rate` | Solve for the minimum hourly rate a freelancer must charge to hit a target annual income, given billable hours, weeks worked, overhead, and a profit margin |
203
204
 
204
- ### API / Network (31 tools)
205
+ ### API / Network (33 tools)
205
206
  | Tool | Description |
206
207
  |------|-------------|
207
208
  | `decode_jwt` | Decode a JWT: header, payload, and expiry |
@@ -235,6 +236,10 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
235
236
  | `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 |
236
237
  | `build_accept_language` | Build an Accept-Language request-header value from locale/quality pairs, sorted by descending preference |
237
238
  | `parse_accept_language` | Parse an existing Accept-Language header value into its ranked locale/quality pairs |
239
+ | `build_prefer_header` | Build an HTTP Prefer request-header value from preferences with optional values/params, per RFC 7240 |
240
+ | `parse_prefer_header` | Parse an existing Prefer header value into its list of preferences, values, and parameters |
241
+ | `build_retry_after_header` | Build a Retry-After response header from a delay in seconds or a target date, per RFC 9110 |
242
+ | `parse_retry_after_header` | Parse an existing Retry-After header value into the same shape |
238
243
 
239
244
  ### Color (4 tools)
240
245
  | Tool | Description |
@@ -272,6 +277,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
272
277
  | `xml_to_json` | Convert XML to JSON |
273
278
  | `parse_csv` | Parse a CSV string and return the data as JSON |
274
279
  | `format_ndjson` | Validate and pretty-print newline-delimited JSON (NDJSON) |
280
+ | `dedupe_ndjson` | Remove duplicate records from NDJSON, by exact match or by a key field |
275
281
  | `diff_env_example` | Diff a .env file against its .env.example template: flags keys missing from either side, plus empty placeholder values |
276
282
  | `parse_markdown_front_matter` | Split a Markdown document into its YAML front matter fields and body content |
277
283
  | `generate_markdown_toc` | Scan a Markdown document's ATX headings and build a nested table of contents with GitHub-style anchor slugs |
package/dist/index.js CHANGED
@@ -2606,6 +2606,53 @@ server.tool(
2606
2606
  return text(formatted);
2607
2607
  }
2608
2608
  );
2609
+ function _dedupeNdjsonMcp(input, keyField) {
2610
+ const validated = _validateNdjson(input);
2611
+ if (!validated.valid) {
2612
+ const firstError = validated.lines.find((l) => !l.valid);
2613
+ return { error: firstError ? `Line ${firstError.line}: ${firstError.error}` : "Invalid NDJSON" };
2614
+ }
2615
+ const seen = /* @__PURE__ */ new Set();
2616
+ const keptRaw = [];
2617
+ const removedLines = [];
2618
+ for (const l of validated.lines) {
2619
+ const parsed = JSON.parse(l.raw);
2620
+ let key;
2621
+ if (keyField) {
2622
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || !(keyField in parsed)) {
2623
+ return { error: `Line ${l.line}: missing key field "${keyField}"` };
2624
+ }
2625
+ key = JSON.stringify(parsed[keyField]);
2626
+ } else {
2627
+ key = JSON.stringify(parsed);
2628
+ }
2629
+ if (seen.has(key)) removedLines.push(l.line);
2630
+ else {
2631
+ seen.add(key);
2632
+ keptRaw.push(l.raw.trim());
2633
+ }
2634
+ }
2635
+ return {
2636
+ totalLines: validated.totalLines,
2637
+ uniqueLines: keptRaw.length,
2638
+ duplicatesRemoved: removedLines.length,
2639
+ output: keptRaw.join("\n"),
2640
+ removedLines
2641
+ };
2642
+ }
2643
+ server.tool(
2644
+ "dedupe_ndjson",
2645
+ "Remove duplicate records from NDJSON (newline-delimited JSON) input. With no keyField, two lines are duplicates when their parsed JSON values are structurally identical. With a keyField, two lines are duplicates when that field's value matches, keeping the first occurrence of each key.",
2646
+ {
2647
+ input: z.string().describe("NDJSON content: one JSON value per line"),
2648
+ keyField: z.string().optional().describe("Optional field name to dedupe by instead of exact structural equality")
2649
+ },
2650
+ async ({ input, keyField }) => {
2651
+ const result = _dedupeNdjsonMcp(input, keyField);
2652
+ if ("error" in result) return errText(result);
2653
+ return text(JSON.stringify(result, null, 2));
2654
+ }
2655
+ );
2609
2656
  function _decodePdfString(raw, isHex) {
2610
2657
  const bytes = [];
2611
2658
  if (isHex) {
@@ -9445,6 +9492,132 @@ server.tool(
9445
9492
  return text(JSON.stringify(result, null, 2));
9446
9493
  }
9447
9494
  );
9495
+ var _PREFER_TOKEN_RE_MCP = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
9496
+ function _isPreferTokenMcp(s) {
9497
+ return _PREFER_TOKEN_RE_MCP.test(s);
9498
+ }
9499
+ function _splitPreferTopLevelMcp(input, delim) {
9500
+ const parts = [];
9501
+ let current = "";
9502
+ let inQuotes = false;
9503
+ for (const ch of input) {
9504
+ if (ch === '"') inQuotes = !inQuotes;
9505
+ if (ch === delim && !inQuotes) {
9506
+ parts.push(current);
9507
+ current = "";
9508
+ } else {
9509
+ current += ch;
9510
+ }
9511
+ }
9512
+ parts.push(current);
9513
+ return parts;
9514
+ }
9515
+ function _parsePreferTokenValueMcp(part) {
9516
+ const trimmed = part.trim();
9517
+ if (!trimmed) return null;
9518
+ const eq = trimmed.indexOf("=");
9519
+ if (eq === -1) return { token: trimmed };
9520
+ const token = trimmed.slice(0, eq).trim();
9521
+ let value = trimmed.slice(eq + 1).trim();
9522
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
9523
+ return { token, value };
9524
+ }
9525
+ function _formatPreferWordMcp(value) {
9526
+ return _isPreferTokenMcp(value) ? value : `"${value.replace(/"/g, '\\"')}"`;
9527
+ }
9528
+ function _formatPreferPreferencesMcp(preferences) {
9529
+ return preferences.map((p) => {
9530
+ let s = p.token;
9531
+ if (p.value !== void 0) s += `=${_formatPreferWordMcp(p.value)}`;
9532
+ for (const param of p.params ?? []) {
9533
+ s += `;${param.name}`;
9534
+ if (param.value !== void 0) s += `=${_formatPreferWordMcp(param.value)}`;
9535
+ }
9536
+ return s;
9537
+ }).join(", ");
9538
+ }
9539
+ function _buildPreferHeaderMcp(preferences) {
9540
+ if (!Array.isArray(preferences) || preferences.length === 0) {
9541
+ return { error: "At least one preference is required" };
9542
+ }
9543
+ const built = [];
9544
+ for (let i = 0; i < preferences.length; i++) {
9545
+ const { token, value, params } = preferences[i];
9546
+ if (typeof token !== "string" || !_isPreferTokenMcp(token.trim())) {
9547
+ return { error: `preference ${i + 1}: token is required and must be a valid HTTP token` };
9548
+ }
9549
+ const cleanParams = [];
9550
+ if (params !== void 0) {
9551
+ if (!Array.isArray(params)) return { error: `preference ${i + 1}: params must be an array` };
9552
+ for (const param of params) {
9553
+ if (typeof param?.name !== "string" || !_isPreferTokenMcp(param.name.trim())) {
9554
+ return { error: `preference ${i + 1}: parameter name is required and must be a valid HTTP token` };
9555
+ }
9556
+ cleanParams.push({ name: param.name.trim(), value: param.value !== void 0 ? String(param.value) : void 0 });
9557
+ }
9558
+ }
9559
+ built.push({
9560
+ token: token.trim(),
9561
+ value: value !== void 0 ? String(value) : void 0,
9562
+ params: cleanParams.length > 0 ? cleanParams : void 0
9563
+ });
9564
+ }
9565
+ const headerValue = _formatPreferPreferencesMcp(built);
9566
+ return { header: `Prefer: ${headerValue}`, headerValue, preferences: built };
9567
+ }
9568
+ function _parsePreferHeaderMcp(raw) {
9569
+ if (!raw || raw.trim() === "") return { error: "Input is empty" };
9570
+ const withoutLabel = raw.trim().replace(/^prefer:\s*/i, "");
9571
+ const parts = _splitPreferTopLevelMcp(withoutLabel, ",").map((p) => p.trim()).filter((p) => p.length > 0);
9572
+ if (parts.length === 0) return { error: "Input is empty" };
9573
+ const preferences = [];
9574
+ for (const part of parts) {
9575
+ const segments = _splitPreferTopLevelMcp(part, ";").map((s) => s.trim()).filter((s) => s.length > 0);
9576
+ if (segments.length === 0) return { error: `Invalid preference: "${part}"` };
9577
+ const main = _parsePreferTokenValueMcp(segments[0]);
9578
+ if (!main || !_isPreferTokenMcp(main.token)) return { error: `Invalid preference token in "${part}"` };
9579
+ const params = [];
9580
+ for (const seg of segments.slice(1)) {
9581
+ const p = _parsePreferTokenValueMcp(seg);
9582
+ if (!p || !_isPreferTokenMcp(p.token)) return { error: `Invalid parameter in "${part}"` };
9583
+ params.push({ name: p.token, value: p.value });
9584
+ }
9585
+ preferences.push({ token: main.token, value: main.value, params: params.length > 0 ? params : void 0 });
9586
+ }
9587
+ const headerValue = _formatPreferPreferencesMcp(preferences);
9588
+ return { header: `Prefer: ${headerValue}`, headerValue, preferences };
9589
+ }
9590
+ server.tool(
9591
+ "build_prefer_header",
9592
+ "Build a Prefer request-header value from a list of preferences, each with an optional value and optional parameters, per RFC 7240. A value or parameter value that is not a valid HTTP token is quoted automatically.",
9593
+ {
9594
+ preferences: z.array(z.object({
9595
+ token: z.string().describe('The preference token, e.g. "respond-async" or "return"'),
9596
+ value: z.string().optional().describe("Optional value for the preference"),
9597
+ params: z.array(z.object({
9598
+ name: z.string(),
9599
+ value: z.string().optional()
9600
+ })).optional().describe("Optional parameters following the preference")
9601
+ })).min(1).describe("Preferences to build the header from")
9602
+ },
9603
+ async ({ preferences }) => {
9604
+ const result = _buildPreferHeaderMcp(preferences);
9605
+ if ("error" in result) return errText(result);
9606
+ return text(JSON.stringify(result, null, 2));
9607
+ }
9608
+ );
9609
+ server.tool(
9610
+ "parse_prefer_header",
9611
+ 'Parse an existing Prefer header value (with or without a leading "Prefer:" label) into its list of preferences, each with an optional value and optional parameters, per RFC 7240.',
9612
+ {
9613
+ header: z.string().min(1).describe("The Prefer header value to parse")
9614
+ },
9615
+ async ({ header }) => {
9616
+ const result = _parsePreferHeaderMcp(header);
9617
+ if ("error" in result) return errText(result);
9618
+ return text(JSON.stringify(result, null, 2));
9619
+ }
9620
+ );
9448
9621
  function _calculateBlendedLoanRateMcp(loans) {
9449
9622
  if (!Array.isArray(loans) || loans.length === 0) {
9450
9623
  return { error: "loans must be a non-empty array" };
@@ -9519,5 +9692,113 @@ server.tool(
9519
9692
  }, null, 2));
9520
9693
  }
9521
9694
  );
9695
+ function _buildRetryAfterHeaderMcp(input) {
9696
+ const { seconds, date } = input;
9697
+ const hasSeconds = seconds !== void 0;
9698
+ const hasDate = date !== void 0 && date !== "";
9699
+ if (hasSeconds && hasDate) return { error: "Provide either seconds or date, not both" };
9700
+ if (!hasSeconds && !hasDate) return { error: "seconds or date is required" };
9701
+ if (hasSeconds) {
9702
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || !Number.isInteger(seconds) || seconds < 0) {
9703
+ return { error: "seconds must be a non-negative integer" };
9704
+ }
9705
+ return { header: `Retry-After: ${seconds}`, headerValue: String(seconds), type: "seconds", seconds };
9706
+ }
9707
+ const parsed = new Date(date);
9708
+ if (Number.isNaN(parsed.getTime())) return { error: "date must be a valid date/time string" };
9709
+ const httpDate = parsed.toUTCString();
9710
+ return { header: `Retry-After: ${httpDate}`, headerValue: httpDate, type: "http-date", httpDate, iso: parsed.toISOString() };
9711
+ }
9712
+ function _parseRetryAfterHeaderMcp(raw) {
9713
+ if (!raw || raw.trim() === "") return { error: "Input is empty" };
9714
+ const value = raw.trim().replace(/^retry-after:\s*/i, "").trim();
9715
+ if (value === "") return { error: "Input is empty" };
9716
+ if (/^\d+$/.test(value)) {
9717
+ const seconds = parseInt(value, 10);
9718
+ return { header: `Retry-After: ${seconds}`, headerValue: String(seconds), type: "seconds", seconds };
9719
+ }
9720
+ const parsed = new Date(value);
9721
+ if (Number.isNaN(parsed.getTime())) {
9722
+ return { error: "Value is neither a non-negative integer (seconds) nor a valid HTTP-date" };
9723
+ }
9724
+ const httpDate = parsed.toUTCString();
9725
+ return { header: `Retry-After: ${httpDate}`, headerValue: httpDate, type: "http-date", httpDate, iso: parsed.toISOString() };
9726
+ }
9727
+ server.tool(
9728
+ "build_retry_after_header",
9729
+ 'Build a Retry-After response header value from either a non-negative delay in seconds or a target date/time, per RFC 9110 Section 10.2.3. A date is formatted as an HTTP-date (IMF-fixdate, e.g. "Wed, 21 Oct 2026 07:28:00 GMT"), the only date format the header allows.',
9730
+ {
9731
+ seconds: z.number().int().min(0).optional().describe("A non-negative delay in seconds (required unless date is given)"),
9732
+ date: z.string().optional().describe("A target date/time string to format as an HTTP-date (required unless seconds is given)")
9733
+ },
9734
+ async ({ seconds, date }) => {
9735
+ const result = _buildRetryAfterHeaderMcp({ seconds, date });
9736
+ if ("error" in result) return errText(result);
9737
+ return text(JSON.stringify(result, null, 2));
9738
+ }
9739
+ );
9740
+ server.tool(
9741
+ "parse_retry_after_header",
9742
+ 'Parse an existing Retry-After header value (with or without a leading "Retry-After:" label) into either its delay-in-seconds form or its HTTP-date form, per RFC 9110 Section 10.2.3.',
9743
+ {
9744
+ header: z.string().min(1).describe("The Retry-After header value to parse")
9745
+ },
9746
+ async ({ header }) => {
9747
+ const result = _parseRetryAfterHeaderMcp(header);
9748
+ if ("error" in result) return errText(result);
9749
+ return text(JSON.stringify(result, null, 2));
9750
+ }
9751
+ );
9752
+ function _calculateFreelancerRateMcp(input) {
9753
+ const { targetAnnualIncome, billableHoursPerWeek, weeksPerYear } = input;
9754
+ const annualOverheadCosts = input.annualOverheadCosts ?? 0;
9755
+ const profitMarginPercent = input.profitMarginPercent ?? 0;
9756
+ if (!Number.isFinite(targetAnnualIncome) || targetAnnualIncome < 0) {
9757
+ return { error: "targetAnnualIncome must be a finite, non-negative number" };
9758
+ }
9759
+ if (!Number.isFinite(billableHoursPerWeek) || billableHoursPerWeek <= 0 || billableHoursPerWeek > 168) {
9760
+ return { error: "billableHoursPerWeek must be a finite number greater than zero and at most 168" };
9761
+ }
9762
+ if (!Number.isFinite(weeksPerYear) || weeksPerYear <= 0 || weeksPerYear > 52) {
9763
+ return { error: "weeksPerYear must be a finite number greater than zero and at most 52" };
9764
+ }
9765
+ if (!Number.isFinite(annualOverheadCosts) || annualOverheadCosts < 0) {
9766
+ return { error: "annualOverheadCosts must be a finite, non-negative number" };
9767
+ }
9768
+ if (!Number.isFinite(profitMarginPercent) || profitMarginPercent < 0) {
9769
+ return { error: "profitMarginPercent must be a finite, non-negative number" };
9770
+ }
9771
+ const round2 = (n) => Math.round(n * 100) / 100;
9772
+ const annualBillableHours = billableHoursPerWeek * weeksPerYear;
9773
+ const baseRevenueNeeded = targetAnnualIncome + annualOverheadCosts;
9774
+ const requiredAnnualRevenue = baseRevenueNeeded * (1 + profitMarginPercent / 100);
9775
+ const hourlyRate = requiredAnnualRevenue / annualBillableHours;
9776
+ return {
9777
+ targetAnnualIncome: round2(targetAnnualIncome),
9778
+ annualOverheadCosts: round2(annualOverheadCosts),
9779
+ profitMarginPercent: round2(profitMarginPercent),
9780
+ annualBillableHours: round2(annualBillableHours),
9781
+ requiredAnnualRevenue: round2(requiredAnnualRevenue),
9782
+ hourlyRate: round2(hourlyRate),
9783
+ dailyRate: round2(hourlyRate * 8),
9784
+ monthlyRevenueTarget: round2(requiredAnnualRevenue / 12)
9785
+ };
9786
+ }
9787
+ server.tool(
9788
+ "calculate_freelancer_rate",
9789
+ "Solve for the minimum hourly rate a freelancer must charge to hit a target annual take-home income, given billable hours per week, weeks actually worked per year, annual business overhead, and an optional profit margin/buffer applied on top.",
9790
+ {
9791
+ targetAnnualIncome: z.number().min(0).describe("Desired annual take-home income"),
9792
+ billableHoursPerWeek: z.number().gt(0).max(168).describe("Billable hours worked per week"),
9793
+ weeksPerYear: z.number().gt(0).max(52).describe("Weeks actually worked per year, after vacation/holidays"),
9794
+ annualOverheadCosts: z.number().min(0).optional().describe("Annual business expenses (software, insurance, etc.), default 0"),
9795
+ profitMarginPercent: z.number().min(0).optional().describe("Extra buffer/profit margin percent applied on top of income plus overhead, default 0")
9796
+ },
9797
+ async ({ targetAnnualIncome, billableHoursPerWeek, weeksPerYear, annualOverheadCosts, profitMarginPercent }) => {
9798
+ const result = _calculateFreelancerRateMcp({ targetAnnualIncome, billableHoursPerWeek, weeksPerYear, annualOverheadCosts, profitMarginPercent });
9799
+ if ("error" in result) return errText(result);
9800
+ return text(JSON.stringify(result, null, 2));
9801
+ }
9802
+ );
9522
9803
  var transport = new StdioServerTransport();
9523
9804
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
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",