@utilix-tech/mcp 0.63.0 → 0.65.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.65.0
6
+ - Add `build_hsts_header`, `parse_hsts_header` tools: build a Strict-Transport-Security response header from a max-age plus includeSubDomains/preload flags, flagging combinations that won't qualify for browser HSTS preload lists, or parse an existing header into the same shape.
7
+ - Add `align_markdown_tables` tool: reformat every GitHub-Flavored-Markdown pipe table in a document so each column's pipes line up visually, preserving left/center/right alignment markers and skipping tables inside fenced code blocks.
8
+
9
+ ## 0.64.0
10
+ - Add `calculate_loan_refinance` tool: compare a current loan's remaining payments against a new refinanced loan on the same outstanding balance, and compute the monthly savings and the break-even month where cumulative savings recoup the closing costs.
11
+ - Add `parse_multipart_form_data` tool: parse a raw multipart/form-data request body into its individual parts (field name, filename, headers, decoded value) per RFC 7578, with an optional Content-Type header value to extract the boundary from.
12
+
5
13
  ## 0.63.0
6
14
  - Add `plan_debt_payoff` tool: simulate paying off a list of debts under both the snowball (smallest balance first) and avalanche (highest rate first) strategies given a fixed extra monthly payment, and compare months to payoff and total interest.
7
15
  - Add `audit_dependency_ranges` tool: classify each dependency's version specifier range type and flag wildcard ranges, the "latest" tag, git/URL/file/link dependencies, and packages listed with conflicting or redundant ranges across sections.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 218 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
3
+ MCP server exposing 223 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 (38 tools)
165
+ ### Units (39 tools)
166
166
  | Tool | Description |
167
167
  |------|-------------|
168
168
  | `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
@@ -203,8 +203,9 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
203
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 |
204
204
  | `calculate_extra_payment_payoff` | Compute how much faster a fixed-rate loan pays off, and how much interest is saved, when a fixed extra amount is added to every monthly payment |
205
205
  | `plan_debt_payoff` | Simulate paying off a list of debts under both the snowball (smallest balance first) and avalanche (highest rate first) strategies given a fixed extra monthly payment, and compare months to payoff and total interest |
206
+ | `calculate_loan_refinance` | Compare a current loan's remaining payments against a new refinanced loan on the same outstanding balance, and compute the monthly savings and the break-even month where cumulative savings recoup the closing costs |
206
207
 
207
- ### API / Network (37 tools)
208
+ ### API / Network (40 tools)
208
209
  | Tool | Description |
209
210
  |------|-------------|
210
211
  | `decode_jwt` | Decode a JWT: header, payload, and expiry |
@@ -246,6 +247,9 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
246
247
  | `parse_priority_header` | Parse an existing Priority header value into its urgency and incremental fields, with spec defaults applied |
247
248
  | `lookup_close_code` | Look up what a WebSocket close code means, per RFC 6455 and the IANA reserved ranges |
248
249
  | `list_close_codes` | List every well-known WebSocket close code (1000-1015) |
250
+ | `parse_multipart_form_data` | Parse a raw multipart/form-data request body into its individual parts: field name, filename, headers, and decoded value, per RFC 7578 |
251
+ | `build_hsts_header` | Build a Strict-Transport-Security response header from a max-age plus includeSubDomains/preload flags, flagging combinations that won't qualify for browser HSTS preload lists |
252
+ | `parse_hsts_header` | Parse an existing Strict-Transport-Security header value into the same shape, with preload-list eligibility flagged |
249
253
 
250
254
  ### Color (4 tools)
251
255
  | Tool | Description |
@@ -275,7 +279,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
275
279
  | `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 |
276
280
  | `audit_dependency_ranges` | Classify each package.json dependency's version range type and flag wildcard ranges, the "latest" tag, git/URL/file/link dependencies, and packages listed with conflicting or redundant ranges across sections |
277
281
 
278
- ### Data (14 tools)
282
+ ### Data (16 tools)
279
283
  | Tool | Description |
280
284
  |------|-------------|
281
285
  | `validate_yaml` | Validate YAML syntax |
@@ -293,6 +297,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
293
297
  | `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 |
294
298
  | `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 |
295
299
  | `infer_csv_column_types` | Parse CSV/TSV data and infer each column's data type (integer, float, boolean, ISO 8601 date, or string), flagging nullable columns and empty-cell counts |
300
+ | `align_markdown_tables` | Reformat every GitHub-Flavored-Markdown pipe table in a document so each column's pipes line up visually, preserving left/center/right alignment markers |
296
301
 
297
302
  ### CSS (2 tools)
298
303
  | Tool | Description |
package/dist/index.js CHANGED
@@ -6120,6 +6120,153 @@ server.tool(
6120
6120
  return text(JSON.stringify(result, null, 2));
6121
6121
  }
6122
6122
  );
6123
+ var _MD_TABLE_DELIM_CELL_RE_MCP = /^:?-+:?$/;
6124
+ function _splitMarkdownTableRowMcp(line) {
6125
+ const raw = [];
6126
+ let current = "";
6127
+ for (let i = 0; i < line.length; i++) {
6128
+ const ch = line[i];
6129
+ if (ch === "\\" && line[i + 1] === "|") {
6130
+ current += "\\|";
6131
+ i++;
6132
+ continue;
6133
+ }
6134
+ if (ch === "|") {
6135
+ raw.push(current);
6136
+ current = "";
6137
+ continue;
6138
+ }
6139
+ current += ch;
6140
+ }
6141
+ raw.push(current);
6142
+ const cells = raw.map((c) => c.trim());
6143
+ if (cells.length > 1 && cells[0] === "") cells.shift();
6144
+ if (cells.length > 1 && cells[cells.length - 1] === "") cells.pop();
6145
+ return cells;
6146
+ }
6147
+ function _isMarkdownTableDelimiterRowMcp(line) {
6148
+ const trimmed = line.trim();
6149
+ if (trimmed === "" || !trimmed.includes("-")) return false;
6150
+ const cells = _splitMarkdownTableRowMcp(trimmed);
6151
+ if (cells.length === 0) return false;
6152
+ return cells.every((c) => _MD_TABLE_DELIM_CELL_RE_MCP.test(c));
6153
+ }
6154
+ function _markdownTableCellAlignmentMcp(delimCell) {
6155
+ const left = delimCell.startsWith(":");
6156
+ const right = delimCell.endsWith(":");
6157
+ if (left && right) return "center";
6158
+ if (right) return "right";
6159
+ if (left) return "left";
6160
+ return "none";
6161
+ }
6162
+ function _padMarkdownTableCellMcp(text2, width, alignment) {
6163
+ const diff = Math.max(0, width - [...text2].length);
6164
+ if (alignment === "right") return " ".repeat(diff) + text2;
6165
+ if (alignment === "center") {
6166
+ const left = Math.floor(diff / 2);
6167
+ const right = diff - left;
6168
+ return " ".repeat(left) + text2 + " ".repeat(right);
6169
+ }
6170
+ return text2 + " ".repeat(diff);
6171
+ }
6172
+ function _markdownTableDelimiterCellMcp(width, alignment) {
6173
+ switch (alignment) {
6174
+ case "left":
6175
+ return ":" + "-".repeat(Math.max(1, width - 1));
6176
+ case "right":
6177
+ return "-".repeat(Math.max(1, width - 1)) + ":";
6178
+ case "center":
6179
+ return ":" + "-".repeat(Math.max(1, width - 2)) + ":";
6180
+ default:
6181
+ return "-".repeat(width);
6182
+ }
6183
+ }
6184
+ function _alignMarkdownTablesMcp(markdown) {
6185
+ if (typeof markdown !== "string") return { error: "markdown must be a string" };
6186
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
6187
+ const outputLines = [...lines];
6188
+ const tables = [];
6189
+ const warnings = [];
6190
+ let inFence = false;
6191
+ let fenceMarker = "";
6192
+ let i = 0;
6193
+ while (i < lines.length) {
6194
+ const line = lines[i];
6195
+ const fenceMatch = line.match(/^\s*(```+|~~~+)/);
6196
+ if (fenceMatch) {
6197
+ if (!inFence) {
6198
+ inFence = true;
6199
+ fenceMarker = fenceMatch[1][0];
6200
+ } else if (fenceMatch[1][0] === fenceMarker) {
6201
+ inFence = false;
6202
+ }
6203
+ i++;
6204
+ continue;
6205
+ }
6206
+ if (inFence) {
6207
+ i++;
6208
+ continue;
6209
+ }
6210
+ const nextLine = lines[i + 1];
6211
+ if (line.includes("|") && nextLine !== void 0 && _isMarkdownTableDelimiterRowMcp(nextLine)) {
6212
+ const headerCells = _splitMarkdownTableRowMcp(line);
6213
+ const delimCells = _splitMarkdownTableRowMcp(nextLine);
6214
+ if (headerCells.length > 0 && headerCells.length === delimCells.length) {
6215
+ const columnCount = headerCells.length;
6216
+ const alignments = delimCells.map(_markdownTableCellAlignmentMcp);
6217
+ let j = i + 2;
6218
+ const bodyRows = [];
6219
+ while (j < lines.length && lines[j].trim() !== "" && lines[j].includes("|")) {
6220
+ bodyRows.push(_splitMarkdownTableRowMcp(lines[j]));
6221
+ j++;
6222
+ }
6223
+ const normalizedBody = [];
6224
+ for (const row of bodyRows) {
6225
+ if (row.length !== columnCount) {
6226
+ warnings.push(`Table at line ${i + 1}: a row has ${row.length} cell(s) but the header has ${columnCount}; it was padded/truncated to match.`);
6227
+ }
6228
+ normalizedBody.push(Array.from({ length: columnCount }, (_, c) => row[c] ?? ""));
6229
+ }
6230
+ const allRows = [headerCells, ...normalizedBody];
6231
+ const widths = Array.from(
6232
+ { length: columnCount },
6233
+ (_, c) => Math.max(3, ...allRows.map((r) => [...r[c] ?? ""].length))
6234
+ );
6235
+ const formattedHeader = "| " + headerCells.map((c, idx) => _padMarkdownTableCellMcp(c, widths[idx], alignments[idx])).join(" | ") + " |";
6236
+ const formattedDelim = "| " + widths.map((w, idx) => _markdownTableDelimiterCellMcp(w, alignments[idx])).join(" | ") + " |";
6237
+ const formattedBody = normalizedBody.map((row) => "| " + row.map((c, idx) => _padMarkdownTableCellMcp(c, widths[idx], alignments[idx])).join(" | ") + " |");
6238
+ const formattedLines = [formattedHeader, formattedDelim, ...formattedBody];
6239
+ for (let k = 0; k < formattedLines.length; k++) {
6240
+ outputLines[i + k] = formattedLines[k];
6241
+ }
6242
+ tables.push({
6243
+ startLine: i + 1,
6244
+ endLine: i + formattedLines.length,
6245
+ columnCount,
6246
+ rowCount: normalizedBody.length,
6247
+ alignments
6248
+ });
6249
+ i = j;
6250
+ continue;
6251
+ }
6252
+ }
6253
+ i++;
6254
+ }
6255
+ if (tables.length === 0) return { error: "No Markdown tables found" };
6256
+ return { output: outputLines.join("\n"), tables, warnings };
6257
+ }
6258
+ server.tool(
6259
+ "align_markdown_tables",
6260
+ "Reformat every GitHub-Flavored-Markdown pipe table in a document so each column's pipes line up visually, padding cells to the widest content in that column and rebuilding the delimiter row to match, while preserving each column's declared alignment. Tables inside fenced code blocks are left untouched.",
6261
+ {
6262
+ markdown: z.string().describe("The Markdown document containing one or more pipe tables")
6263
+ },
6264
+ async ({ markdown }) => {
6265
+ const result = _alignMarkdownTablesMcp(markdown);
6266
+ if ("error" in result) return errText(result);
6267
+ return text(JSON.stringify(result, null, 2));
6268
+ }
6269
+ );
6123
6270
  var _COMMIT_DEFAULT_TYPES_MCP = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test"];
6124
6271
  var _COMMIT_HEADER_RE_MCP = /^([A-Za-z][A-Za-z0-9-]*)(\(([^()]*)\))?(!)?: (.+)$/;
6125
6272
  var _COMMIT_FOOTER_LINE_RE_MCP = /^([A-Za-z][A-Za-z0-9-]*|BREAKING CHANGE)(: ?| #)(.*)$/;
@@ -9968,6 +10115,112 @@ server.tool(
9968
10115
  return text(JSON.stringify({ codes }, null, 2));
9969
10116
  }
9970
10117
  );
10118
+ var _HSTS_ONE_YEAR_SECONDS_MCP = 31536e3;
10119
+ function _humanizeHstsMaxAgeMcp(seconds) {
10120
+ if (seconds === 0) return "0 seconds (clears HSTS)";
10121
+ if (seconds % _HSTS_ONE_YEAR_SECONDS_MCP === 0) {
10122
+ const years = seconds / _HSTS_ONE_YEAR_SECONDS_MCP;
10123
+ return `${years} year${years === 1 ? "" : "s"}`;
10124
+ }
10125
+ if (seconds % 86400 === 0) {
10126
+ const days = seconds / 86400;
10127
+ return `${days} day${days === 1 ? "" : "s"}`;
10128
+ }
10129
+ if (seconds % 3600 === 0) {
10130
+ const hours = seconds / 3600;
10131
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
10132
+ }
10133
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
10134
+ }
10135
+ function _buildHstsMcp(options) {
10136
+ const { maxAge, includeSubDomains = false, preload = false } = options;
10137
+ if (!Number.isInteger(maxAge) || maxAge < 0) {
10138
+ return { error: "maxAge must be a non-negative integer number of seconds" };
10139
+ }
10140
+ const warnings = [];
10141
+ const preloadEligible = maxAge >= _HSTS_ONE_YEAR_SECONDS_MCP && includeSubDomains && preload;
10142
+ if (preload && !includeSubDomains) {
10143
+ warnings.push("Browser HSTS preload lists require includeSubDomains whenever preload is set; this header will not qualify for preloading as configured.");
10144
+ }
10145
+ if (preload && maxAge < _HSTS_ONE_YEAR_SECONDS_MCP) {
10146
+ warnings.push(`Browser HSTS preload lists require max-age of at least ${_HSTS_ONE_YEAR_SECONDS_MCP} (1 year); ${maxAge} is too low to qualify.`);
10147
+ }
10148
+ if (maxAge === 0) {
10149
+ warnings.push("max-age=0 tells the browser to forget this host supports HSTS immediately, this clears HSTS rather than enabling it.");
10150
+ } else if (maxAge < 86400) {
10151
+ warnings.push("A max-age under a day gives very little protection; most real deployments use at least 15552000 (180 days) once confident HTTPS is fully working.");
10152
+ }
10153
+ const parts = [`max-age=${maxAge}`];
10154
+ if (includeSubDomains) parts.push("includeSubDomains");
10155
+ if (preload) parts.push("preload");
10156
+ const header = parts.join("; ");
10157
+ return {
10158
+ header,
10159
+ headerName: "Strict-Transport-Security",
10160
+ snippet: `Strict-Transport-Security: ${header}`,
10161
+ maxAge,
10162
+ maxAgeHuman: _humanizeHstsMaxAgeMcp(maxAge),
10163
+ includeSubDomains,
10164
+ preload,
10165
+ preloadEligible,
10166
+ warnings
10167
+ };
10168
+ }
10169
+ function _parseHstsMcp(raw) {
10170
+ if (!raw || raw.trim() === "") return { error: "Input is empty" };
10171
+ const withoutLabel = raw.trim().replace(/^strict-transport-security:\s*/i, "");
10172
+ const tokens = withoutLabel.split(";").map((t) => t.trim()).filter((t) => t !== "");
10173
+ if (tokens.length === 0) return { error: "No directives found" };
10174
+ let maxAge = null;
10175
+ let includeSubDomains = false;
10176
+ let preload = false;
10177
+ for (const token of tokens) {
10178
+ const eq = token.indexOf("=");
10179
+ const name = (eq === -1 ? token : token.slice(0, eq)).trim().toLowerCase();
10180
+ if (name === "max-age") {
10181
+ const rawValue = eq === -1 ? "" : token.slice(eq + 1).trim();
10182
+ const n = Number(rawValue);
10183
+ if (rawValue === "" || !Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
10184
+ return { error: `max-age value "${rawValue}" is not a valid non-negative integer` };
10185
+ }
10186
+ maxAge = n;
10187
+ } else if (name === "includesubdomains") {
10188
+ includeSubDomains = true;
10189
+ } else if (name === "preload") {
10190
+ preload = true;
10191
+ } else {
10192
+ return { error: `Unrecognized directive "${token}"` };
10193
+ }
10194
+ }
10195
+ if (maxAge === null) return { error: "max-age directive is required" };
10196
+ return _buildHstsMcp({ maxAge, includeSubDomains, preload });
10197
+ }
10198
+ server.tool(
10199
+ "build_hsts_header",
10200
+ "Build a Strict-Transport-Security response-header value from a max-age (in seconds) plus optional includeSubDomains and preload directives, flagging combinations that will not qualify for browser HSTS preload lists (which require max-age >= 1 year, includeSubDomains, and preload all together).",
10201
+ {
10202
+ maxAge: z.number().int().min(0).describe("How long, in seconds, the browser should remember this host is HTTPS-only, e.g. 31536000 for 1 year"),
10203
+ includeSubDomains: z.boolean().optional().describe("Whether the policy also applies to all subdomains, default false"),
10204
+ preload: z.boolean().optional().describe("Whether to opt into browser HSTS preload lists, default false")
10205
+ },
10206
+ async ({ maxAge, includeSubDomains, preload }) => {
10207
+ const result = _buildHstsMcp({ maxAge, includeSubDomains, preload });
10208
+ if ("error" in result) return errText(result);
10209
+ return text(JSON.stringify(result, null, 2));
10210
+ }
10211
+ );
10212
+ server.tool(
10213
+ "parse_hsts_header",
10214
+ 'Parse an existing Strict-Transport-Security header value (with or without a leading "Strict-Transport-Security:" label) into its max-age, includeSubDomains, and preload fields, flagging whether it qualifies for browser HSTS preload lists.',
10215
+ {
10216
+ header: z.string().min(1).describe("The Strict-Transport-Security header value to parse")
10217
+ },
10218
+ async ({ header }) => {
10219
+ const result = _parseHstsMcp(header);
10220
+ if ("error" in result) return errText(result);
10221
+ return text(JSON.stringify(result, null, 2));
10222
+ }
10223
+ );
9971
10224
  var _DEBT_PAYOFF_MAX_MONTHS_MCP = 1200;
9972
10225
  function _simulateDebtPayoffMcp(debts, order, extraMonthlyPayment) {
9973
10226
  const round2 = (n) => Math.round(n * 100) / 100;
@@ -10187,5 +10440,140 @@ server.tool(
10187
10440
  return text(JSON.stringify(result, null, 2));
10188
10441
  }
10189
10442
  );
10443
+ function _calculateLoanForRefinanceMcp(principal, annualRatePercent, termMonths) {
10444
+ if (!Number.isFinite(principal) || principal <= 0) return { error: "Principal must be a positive number" };
10445
+ if (!Number.isFinite(annualRatePercent) || annualRatePercent < 0) {
10446
+ return { error: "Annual interest rate must be zero or a positive number" };
10447
+ }
10448
+ if (!Number.isInteger(termMonths) || termMonths <= 0) {
10449
+ return { error: "Term must be a positive whole number of months" };
10450
+ }
10451
+ const monthlyRate = annualRatePercent / 100 / 12;
10452
+ let monthlyPayment;
10453
+ if (monthlyRate === 0) {
10454
+ monthlyPayment = principal / termMonths;
10455
+ } else {
10456
+ const factor = Math.pow(1 + monthlyRate, termMonths);
10457
+ monthlyPayment = principal * monthlyRate * factor / (factor - 1);
10458
+ }
10459
+ let balance = _roundMoneyMcp(principal);
10460
+ let totalPayment = 0;
10461
+ for (let month = 1; month <= termMonths; month++) {
10462
+ const interest = monthlyRate === 0 ? 0 : _roundMoneyMcp(balance * monthlyRate);
10463
+ let principalPortion = _roundMoneyMcp(monthlyPayment) - interest;
10464
+ if (month === termMonths || principalPortion > balance) principalPortion = balance;
10465
+ const payment = _roundMoneyMcp(principalPortion + interest);
10466
+ balance = _roundMoneyMcp(balance - principalPortion);
10467
+ totalPayment += payment;
10468
+ }
10469
+ return {
10470
+ monthlyPayment: _roundMoneyMcp(monthlyPayment),
10471
+ totalPayment: _roundMoneyMcp(totalPayment),
10472
+ totalInterest: _roundMoneyMcp(totalPayment - principal),
10473
+ termMonths
10474
+ };
10475
+ }
10476
+ function _calculateLoanRefinanceMcp(currentBalance, currentRatePercent, currentRemainingMonths, newRatePercent, newTermMonths, closingCosts) {
10477
+ const current = _calculateLoanForRefinanceMcp(currentBalance, currentRatePercent, currentRemainingMonths);
10478
+ if ("error" in current) return current;
10479
+ const refinanced = _calculateLoanForRefinanceMcp(currentBalance, newRatePercent, newTermMonths);
10480
+ if ("error" in refinanced) return refinanced;
10481
+ if (!Number.isFinite(closingCosts) || closingCosts < 0) {
10482
+ return { error: "Closing costs must be zero or a positive number" };
10483
+ }
10484
+ const monthlySavings = _roundMoneyMcp(current.monthlyPayment - refinanced.monthlyPayment);
10485
+ const breakEvenMonths = monthlySavings > 0 ? Math.ceil(closingCosts / monthlySavings) : null;
10486
+ return {
10487
+ currentMonthlyPayment: current.monthlyPayment,
10488
+ newMonthlyPayment: refinanced.monthlyPayment,
10489
+ monthlySavings,
10490
+ closingCosts: _roundMoneyMcp(closingCosts),
10491
+ breakEvenMonths,
10492
+ currentTotalInterest: current.totalInterest,
10493
+ newTotalInterest: refinanced.totalInterest,
10494
+ totalInterestSavings: _roundMoneyMcp(current.totalInterest - refinanced.totalInterest)
10495
+ };
10496
+ }
10497
+ server.tool(
10498
+ "calculate_loan_refinance",
10499
+ "Compare a current loan's remaining payments against a new refinanced loan on the same outstanding balance, and compute the monthly savings and the break-even month where cumulative monthly savings recoup the closing costs.",
10500
+ {
10501
+ currentBalance: z.number().describe("Outstanding balance on the current loan"),
10502
+ currentRatePercent: z.number().describe("Current loan's annual interest rate as a percent, e.g. 6.5"),
10503
+ currentRemainingMonths: z.number().int().describe("Months remaining on the current loan"),
10504
+ newRatePercent: z.number().describe("New (refinanced) loan's annual interest rate as a percent, e.g. 5.5"),
10505
+ newTermMonths: z.number().int().describe("New loan's term in months"),
10506
+ closingCosts: z.number().describe("Total closing costs to refinance")
10507
+ },
10508
+ async ({ currentBalance, currentRatePercent, currentRemainingMonths, newRatePercent, newTermMonths, closingCosts }) => {
10509
+ const result = _calculateLoanRefinanceMcp(currentBalance, currentRatePercent, currentRemainingMonths, newRatePercent, newTermMonths, closingCosts);
10510
+ if ("error" in result) return errText(result);
10511
+ return text(JSON.stringify(result, null, 2));
10512
+ }
10513
+ );
10514
+ function _extractMultipartBoundaryMcp(contentType) {
10515
+ if (!contentType) return null;
10516
+ const match = contentType.match(/boundary=(?:"([^"]*)"|([^;\s]+))/i);
10517
+ if (!match) return null;
10518
+ return match[1] ?? match[2] ?? null;
10519
+ }
10520
+ function _parseMultipartFormDataMcp(body, boundary) {
10521
+ if (!body || !body.trim()) return { error: "Body is empty" };
10522
+ if (!boundary || !boundary.trim()) return { error: "Boundary is required" };
10523
+ const normalized = body.replace(/\r\n/g, "\n");
10524
+ const delimiter = `--${boundary}`;
10525
+ const segments = normalized.split(delimiter);
10526
+ if (segments.length < 3) {
10527
+ return { error: `No parts found for boundary "${boundary}"; check that it matches the boundary used in the body` };
10528
+ }
10529
+ const parts = [];
10530
+ for (let i = 1; i < segments.length; i++) {
10531
+ let segment = segments[i];
10532
+ if (segment.startsWith("--")) break;
10533
+ segment = segment.replace(/^\n/, "").replace(/\n$/, "");
10534
+ const headerEnd = segment.indexOf("\n\n");
10535
+ if (headerEnd === -1) continue;
10536
+ const headerBlock = segment.slice(0, headerEnd);
10537
+ const value = segment.slice(headerEnd + 2);
10538
+ const headers = {};
10539
+ for (const line of headerBlock.split("\n")) {
10540
+ const colon = line.indexOf(":");
10541
+ if (colon === -1) continue;
10542
+ headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
10543
+ }
10544
+ const disposition = headers["content-disposition"] ?? "";
10545
+ const nameMatch = disposition.match(/name="([^"]*)"/);
10546
+ const filenameMatch = disposition.match(/filename="([^"]*)"/);
10547
+ const partContentType = headers["content-type"] ?? "";
10548
+ const isBinary = !!filenameMatch && !partContentType.startsWith("text/") && !partContentType.includes("json") && !partContentType.includes("xml");
10549
+ parts.push({
10550
+ name: nameMatch ? nameMatch[1] : null,
10551
+ filename: filenameMatch ? filenameMatch[1] : null,
10552
+ headers,
10553
+ value,
10554
+ isBinary
10555
+ });
10556
+ }
10557
+ if (parts.length === 0) {
10558
+ return { error: `No parts found for boundary "${boundary}"; check that it matches the boundary used in the body` };
10559
+ }
10560
+ return { boundary, parts };
10561
+ }
10562
+ server.tool(
10563
+ "parse_multipart_form_data",
10564
+ "Parse a raw multipart/form-data request body into its individual parts: field name, filename (for file fields), headers, and decoded value, per RFC 7578. Give the boundary directly, or the request's Content-Type header value to extract it from automatically.",
10565
+ {
10566
+ body: z.string().min(1).describe("The raw multipart/form-data request body"),
10567
+ boundary: z.string().optional().describe("The multipart boundary (without the leading --), if known directly"),
10568
+ contentType: z.string().optional().describe("Alternative to boundary: the request's Content-Type header value, from which the boundary is extracted")
10569
+ },
10570
+ async ({ body, boundary, contentType }) => {
10571
+ const resolvedBoundary = boundary ?? (contentType ? _extractMultipartBoundaryMcp(contentType) : null);
10572
+ if (!resolvedBoundary) return errText({ error: "boundary is required (either directly, or via a contentType header containing a boundary parameter)" });
10573
+ const result = _parseMultipartFormDataMcp(body, resolvedBoundary);
10574
+ if ("error" in result) return errText(result);
10575
+ return text(JSON.stringify(result, null, 2));
10576
+ }
10577
+ );
10190
10578
  var transport = new StdioServerTransport();
10191
10579
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.63.0",
3
+ "version": "0.65.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",