@utilix-tech/mcp 0.32.0 → 0.34.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.34.0
6
+ - Add `lint_conventional_commit` tool: lint a git commit message against the Conventional Commits v1.0.0 spec, checking header format, blank-line separation, footer tokens, and BREAKING CHANGE casing.
7
+ - Add `calculate_savings_goal` tool: solve for months to reach a savings goal at a fixed monthly contribution, or the monthly contribution required to hit a goal by a target month count.
8
+
9
+ ## 0.33.0
10
+ - Add `analyze_sentence_lengths` tool: histogram of sentence lengths in words, plus mean, median, standard deviation, and a coefficient-of-variation consistency label, distinct from readability grade scores.
11
+ - Add `format_few_shot_examples` tool: format input/output example pairs into a numbered, XML-tagged, or delimiter-separated few-shot prompt template.
12
+
5
13
  ## 0.32.0
6
14
  - Add `calculate_emi` tool: equated monthly installment, total interest, and a year-by-year principal/interest breakdown from principal, annual rate, and tenure in years or months.
7
15
  - Add `parse_markdown_front_matter` tool: split a Markdown document into its YAML front matter fields and body content, parsing scalars, inline arrays, and block lists.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 150 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
3
+ MCP server exposing 154 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
 
@@ -128,7 +128,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
128
128
  | `generate_qr` | Generate a QR code as an SVG string |
129
129
  | `generate_random_data` | Generate random mock data: names, emails, UUIDs, IPs, etc. |
130
130
 
131
- ### Text (13 tools)
131
+ ### Text (14 tools)
132
132
  | Tool | Description |
133
133
  |------|-------------|
134
134
  | `convert_case` | Convert text case: camelCase, snake_case, PascalCase, etc. |
@@ -144,6 +144,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
144
144
  | `score_readability` | Score text with Flesch Reading Ease, Flesch-Kincaid Grade Level, and Gunning Fog Index |
145
145
  | `detect_filler_words` | Scan text for filler words, hedge phrases, and clichés with per-phrase counts and a density score |
146
146
  | `calculate_string_distance` | Compute the Levenshtein (or Damerau) edit distance and normalized similarity between two strings |
147
+ | `analyze_sentence_lengths` | Split text into sentences and report a histogram of sentence lengths, plus mean, median, standard deviation, and a consistency label |
147
148
 
148
149
  ### Time (6 tools)
149
150
  | Tool | Description |
@@ -155,7 +156,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
155
156
  | `convert_timezone` | Convert a date/time between timezones |
156
157
  | `convert_iso8601_duration` | Parse an ISO 8601 duration into components and total seconds, or format seconds into a duration string |
157
158
 
158
- ### Units (21 tools)
159
+ ### Units (22 tools)
159
160
  | Tool | Description |
160
161
  |------|-------------|
161
162
  | `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
@@ -179,6 +180,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
179
180
  | `calculate_video_bitrate` | Calculate a video's file size from a bitrate and duration, or the bitrate needed to hit a target file size |
180
181
  | `calculate_rule_of_72` | Estimate years to double an investment from a rate, or the rate needed to double in a given number of years, via the Rule of 72 shortcut and the exact logarithmic result |
181
182
  | `calculate_emi` | Compute the Equated Monthly Installment for a loan from principal, annual rate, and tenure in years or months, with a year-by-year principal/interest breakdown |
183
+ | `calculate_savings_goal` | Solve for months to reach a savings goal at a fixed monthly contribution, or the monthly contribution required to hit a goal by a target month count |
182
184
 
183
185
  ### API / Network (12 tools)
184
186
  | Tool | Description |
@@ -204,7 +206,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
204
206
  | `color_palette` | Generate a color palette from a base color |
205
207
  | `color_shades` | Generate shades or tints from a base color |
206
208
 
207
- ### Code (10 tools)
209
+ ### Code (11 tools)
208
210
  | Tool | Description |
209
211
  |------|-------------|
210
212
  | `format_sql` | Format a SQL query with proper indentation |
@@ -217,6 +219,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
217
219
  | `lookup_mime` | Look up the MIME type for a file extension, or the extension(s) for a MIME type |
218
220
  | `parse_github_url` | Parse a github.com URL (repo, pull request, issue, commit, file, or release) into structured components |
219
221
  | `detect_redos` | Statically flag catastrophic-backtracking (ReDoS) risk in a regex: nested quantifiers, overlapping alternation, adjacent overlapping quantifiers |
222
+ | `lint_conventional_commit` | Lint a commit message against the Conventional Commits v1.0.0 spec: header format, blank-line separation, footer tokens, and BREAKING CHANGE casing |
220
223
 
221
224
  ### Data (7 tools)
222
225
  | Tool | Description |
@@ -242,7 +245,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
242
245
  | `unicode_inspect` | Inspect Unicode code points of a string |
243
246
  | `optimize_svg` | Optimize and minify an SVG string |
244
247
 
245
- ### AI / Agent (26 tools)
248
+ ### AI / Agent (27 tools)
246
249
  | Tool | Description |
247
250
  |------|-------------|
248
251
  | `estimate_tokens` | Estimate token count and cost for an LLM prompt |
@@ -271,6 +274,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
271
274
  | `compress_json` | Compress JSON for LLM context budgets |
272
275
  | `diff_json_detailed` | Diff two parsed JSON values, returning every path with its op and unchanged paths |
273
276
  | `vector_similarity` | Cosine similarity, dot product, and Euclidean / Manhattan distance between two numeric vectors |
277
+ | `format_few_shot_examples` | Format input/output example pairs into a numbered, XML-tagged, or delimiter-separated few-shot prompt template |
274
278
 
275
279
  ### Image (3 tools)
276
280
  | Tool | Description |
package/dist/index.js CHANGED
@@ -1092,6 +1092,30 @@ server.tool("chunk_text", "Split text into overlapping chunks for RAG.", { text:
1092
1092
  server.tool("extract_urls", "Extract all URLs from text or HTML.", { text: z.string() }, async ({ text: text2 }) => {
1093
1093
  return { content: [{ type: "text", text: JSON.stringify(extractUrls(text2), null, 2) }] };
1094
1094
  });
1095
+ function _formatFewShotExamples(examples, format, delimiter) {
1096
+ const valid = (examples ?? []).filter((e) => e?.input?.trim() && e?.output?.trim());
1097
+ if (valid.length === 0) return { formatted: "", exampleCount: 0, format };
1098
+ let formatted;
1099
+ if (format === "xml") {
1100
+ formatted = valid.map((e) => `<example>
1101
+ <input>${e.input.trim()}</input>
1102
+ <output>${e.output.trim()}</output>
1103
+ </example>`).join("\n\n");
1104
+ } else if (format === "delimiter") {
1105
+ formatted = valid.map((e) => `Input: ${e.input.trim()}
1106
+ Output: ${e.output.trim()}`).join(`
1107
+ ${delimiter}
1108
+ `);
1109
+ } else {
1110
+ formatted = valid.map((e, i) => `Example ${i + 1}:
1111
+ Input: ${e.input.trim()}
1112
+ Output: ${e.output.trim()}`).join("\n\n");
1113
+ }
1114
+ return { formatted, exampleCount: valid.length, format };
1115
+ }
1116
+ server.tool("format_few_shot_examples", "Format a list of input/output example pairs into a standard few-shot prompt template: numbered, XML-tagged, or delimiter-separated.", { examples: z.array(z.object({ input: z.string(), output: z.string() })), format: z.enum(["numbered", "xml", "delimiter"]).default("numbered"), delimiter: z.string().default("---") }, async ({ examples, format, delimiter }) => {
1117
+ return { content: [{ type: "text", text: JSON.stringify(_formatFewShotExamples(examples, format, delimiter), null, 2) }] };
1118
+ });
1095
1119
  server.tool("sanitize_html", "Strip HTML tags, scripts, and event handlers for LLM ingestion.", { html: z.string(), keep_links: z.boolean().default(false) }, async ({ html, keep_links }) => {
1096
1120
  return { content: [{ type: "text", text: JSON.stringify(sanitizeHtml(html, keep_links), null, 2) }] };
1097
1121
  });
@@ -3138,6 +3162,74 @@ function _scoreReadability(input) {
3138
3162
  server.tool("score_readability", "Score text with Flesch Reading Ease, Flesch-Kincaid Grade Level, and Gunning Fog Index, using a heuristic syllable counter.", { text: z.string() }, async ({ text: input }) => {
3139
3163
  return { content: [{ type: "text", text: JSON.stringify(_scoreReadability(input), null, 2) }] };
3140
3164
  });
3165
+ function _splitIntoSentencesForLengthDist(text2) {
3166
+ const matches = text2.match(/[^.!?]+[.!?]*/g);
3167
+ if (!matches) return [];
3168
+ return matches.map((s) => s.trim()).filter((s) => s.length > 0);
3169
+ }
3170
+ function _countWordsInSentenceForLengthDist(sentence) {
3171
+ return (sentence.match(/[a-zA-Z']+/g) ?? []).length;
3172
+ }
3173
+ function _analyzeSentenceLengths(input) {
3174
+ const sentences = _splitIntoSentencesForLengthDist(input);
3175
+ const lengths = sentences.map(_countWordsInSentenceForLengthDist).filter((n) => n > 0);
3176
+ if (lengths.length === 0) {
3177
+ return {
3178
+ sentenceCount: 0,
3179
+ lengths: [],
3180
+ minLength: 0,
3181
+ maxLength: 0,
3182
+ meanLength: 0,
3183
+ medianLength: 0,
3184
+ stdDev: 0,
3185
+ coefficientOfVariation: 0,
3186
+ consistency: "No sentences to analyze",
3187
+ buckets: []
3188
+ };
3189
+ }
3190
+ const sentenceCount = lengths.length;
3191
+ const minLength = Math.min(...lengths);
3192
+ const maxLength = Math.max(...lengths);
3193
+ const meanLength = lengths.reduce((a, b) => a + b, 0) / sentenceCount;
3194
+ const sorted = [...lengths].sort((a, b) => a - b);
3195
+ const mid = Math.floor(sorted.length / 2);
3196
+ const medianLength = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
3197
+ const variance = lengths.reduce((sum, n) => sum + (n - meanLength) ** 2, 0) / sentenceCount;
3198
+ const stdDev = Math.sqrt(variance);
3199
+ const coefficientOfVariation = meanLength > 0 ? stdDev / meanLength : 0;
3200
+ let consistency;
3201
+ if (coefficientOfVariation < 0.3) consistency = "Consistent \u2014 sentence lengths vary little";
3202
+ else if (coefficientOfVariation < 0.6) consistency = "Moderate variation in sentence length";
3203
+ else consistency = "Highly variable sentence length";
3204
+ const bucketWidth = 5;
3205
+ const bucketCount = Math.max(1, Math.ceil(maxLength / bucketWidth));
3206
+ const buckets = [];
3207
+ for (let i = 0; i < bucketCount; i++) {
3208
+ const min = i * bucketWidth + 1;
3209
+ const max = (i + 1) * bucketWidth;
3210
+ buckets.push({ label: `${min}-${max}`, min, max, count: 0 });
3211
+ }
3212
+ for (const len of lengths) {
3213
+ const idx = Math.min(Math.floor((len - 1) / bucketWidth), bucketCount - 1);
3214
+ buckets[idx].count++;
3215
+ }
3216
+ const round2 = (n) => Math.round(n * 100) / 100;
3217
+ return {
3218
+ sentenceCount,
3219
+ lengths,
3220
+ minLength,
3221
+ maxLength,
3222
+ meanLength: round2(meanLength),
3223
+ medianLength: round2(medianLength),
3224
+ stdDev: round2(stdDev),
3225
+ coefficientOfVariation: round2(coefficientOfVariation),
3226
+ consistency,
3227
+ buckets
3228
+ };
3229
+ }
3230
+ server.tool("analyze_sentence_lengths", "Split text into sentences and report a histogram of sentence lengths in words, plus mean, median, standard deviation, and a consistency label. Distinct from readability grade scores.", { text: z.string() }, async ({ text: input }) => {
3231
+ return { content: [{ type: "text", text: JSON.stringify(_analyzeSentenceLengths(input), null, 2) }] };
3232
+ });
3141
3233
  var _ID3V1_GENRES = [
3142
3234
  "Blues",
3143
3235
  "Classic Rock",
@@ -5546,5 +5638,218 @@ server.tool(
5546
5638
  return text(JSON.stringify(result, null, 2));
5547
5639
  }
5548
5640
  );
5641
+ var _COMMIT_DEFAULT_TYPES_MCP = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test"];
5642
+ var _COMMIT_HEADER_RE_MCP = /^([A-Za-z][A-Za-z0-9-]*)(\(([^()]*)\))?(!)?: (.+)$/;
5643
+ var _COMMIT_FOOTER_LINE_RE_MCP = /^([A-Za-z][A-Za-z0-9-]*|BREAKING CHANGE)(: ?| #)(.*)$/;
5644
+ var _COMMIT_BREAKING_LOOKALIKE_RE_MCP = /^([A-Za-z][A-Za-z -]*?)(: | #)(.*)$/;
5645
+ var _COMMIT_BREAKING_TOKEN_RE_MCP = /^breaking[- ]change$/i;
5646
+ function _lintConventionalCommitMcp(message, types, maxHeaderLength) {
5647
+ if (typeof message !== "string" || message.trim() === "") return { error: "message is required" };
5648
+ if (message.length > 2e4) return { error: "message too long (max 20,000 chars)" };
5649
+ const commitTypes = types && types.length > 0 ? types : _COMMIT_DEFAULT_TYPES_MCP;
5650
+ const headerLimit = maxHeaderLength ?? 100;
5651
+ const findings = [];
5652
+ const lines = message.replace(/\r\n/g, "\n").split("\n");
5653
+ const header = lines[0];
5654
+ const match = header.match(_COMMIT_HEADER_RE_MCP);
5655
+ const parsed = {
5656
+ header,
5657
+ type: null,
5658
+ scope: null,
5659
+ breaking: false,
5660
+ description: null,
5661
+ body: null,
5662
+ footers: []
5663
+ };
5664
+ if (!match) {
5665
+ findings.push({ rule: "header-format", severity: "error", message: "Header must match '<type>[(scope)][!]: <description>', e.g. 'feat(auth): add OAuth2 login'. A single space must follow the colon." });
5666
+ return { message, valid: false, parsed, findings };
5667
+ }
5668
+ const [, type, scopeGroup, scope, bang, description] = match;
5669
+ parsed.type = type;
5670
+ if (scopeGroup !== void 0) {
5671
+ const scopeContent = (scope ?? "").trim();
5672
+ if (scopeContent === "") {
5673
+ findings.push({ rule: "scope-empty", severity: "error", message: "Scope parentheses are present but empty; either remove the parentheses or put a scope inside them, e.g. fix(parser):." });
5674
+ } else {
5675
+ parsed.scope = scopeContent;
5676
+ }
5677
+ }
5678
+ const trimmedDescription = description.trim();
5679
+ if (trimmedDescription === "") {
5680
+ findings.push({ rule: "subject-empty", severity: "error", message: "The description after the colon must not be empty." });
5681
+ } else {
5682
+ parsed.description = trimmedDescription;
5683
+ if (trimmedDescription.endsWith(".")) {
5684
+ findings.push({ rule: "subject-full-stop", severity: "warning", message: "The description should not end with a period." });
5685
+ }
5686
+ }
5687
+ if (!commitTypes.includes(type.toLowerCase())) {
5688
+ findings.push({ rule: "type-unknown", severity: "warning", message: `'${type}' is not one of the recognized types (${commitTypes.join(", ")}). This is only a convention, not a spec requirement, so a custom type is fine if your team has agreed on it.` });
5689
+ }
5690
+ if (type !== type.toLowerCase()) {
5691
+ findings.push({ rule: "type-case", severity: "warning", message: "The type is conventionally lower-case. The spec itself is not case-sensitive about this, but nearly every tool and team defaults to lower-case." });
5692
+ }
5693
+ if (header.length > headerLimit) {
5694
+ findings.push({ rule: "header-max-length", severity: "warning", message: `Header is ${header.length} characters, longer than the recommended maximum of ${headerLimit}.` });
5695
+ }
5696
+ let breaking = bang === "!";
5697
+ if (lines.length > 1) {
5698
+ if (lines[1].trim() !== "") {
5699
+ findings.push({ rule: "missing-blank-line-before-body", severity: "error", message: "A blank line must separate the header from the body or footer." });
5700
+ }
5701
+ const rest = lines.slice(1).join("\n");
5702
+ const paragraphs = rest.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p !== "");
5703
+ let footerParagraph = null;
5704
+ if (paragraphs.length > 0) {
5705
+ const last = paragraphs[paragraphs.length - 1];
5706
+ const lastLines = last.split("\n");
5707
+ if (lastLines.every((l) => _COMMIT_FOOTER_LINE_RE_MCP.test(l.trim()))) {
5708
+ footerParagraph = last;
5709
+ paragraphs.pop();
5710
+ }
5711
+ }
5712
+ parsed.body = paragraphs.length > 0 ? paragraphs.join("\n\n") : null;
5713
+ const footers2 = parsed.footers;
5714
+ if (footerParagraph) {
5715
+ for (const line of footerParagraph.split("\n")) {
5716
+ const fm = line.trim().match(_COMMIT_FOOTER_LINE_RE_MCP);
5717
+ if (!fm) continue;
5718
+ const [, token, , value] = fm;
5719
+ footers2.push({ token, value: value.trim() });
5720
+ }
5721
+ }
5722
+ for (const f of footers2) {
5723
+ if (_COMMIT_BREAKING_TOKEN_RE_MCP.test(f.token) && f.token !== "BREAKING CHANGE" && f.token !== "BREAKING-CHANGE") {
5724
+ findings.push({ rule: "breaking-change-casing", severity: "error", message: `Found '${f.token}:' as a footer, but the spec requires the breaking-change token to be exactly 'BREAKING CHANGE' or 'BREAKING-CHANGE' (uppercase).` });
5725
+ }
5726
+ }
5727
+ for (const p of paragraphs) {
5728
+ for (const line of p.split("\n")) {
5729
+ const bm = line.trim().match(_COMMIT_BREAKING_LOOKALIKE_RE_MCP);
5730
+ if (bm && _COMMIT_BREAKING_TOKEN_RE_MCP.test(bm[1]) && bm[1] !== "BREAKING CHANGE") {
5731
+ findings.push({ rule: "breaking-change-casing", severity: "error", message: `Found '${bm[1]}' near the end of the message, but it wasn't recognized as a breaking-change footer. The spec requires the exact uppercase token 'BREAKING CHANGE' (or 'BREAKING-CHANGE'), immediately followed by ': '.` });
5732
+ }
5733
+ }
5734
+ }
5735
+ }
5736
+ const footers = parsed.footers;
5737
+ const breakingFooter = footers.find((f) => f.token === "BREAKING CHANGE" || f.token === "BREAKING-CHANGE");
5738
+ if (breakingFooter) {
5739
+ breaking = true;
5740
+ if (breakingFooter.value === "") {
5741
+ findings.push({ rule: "breaking-change-empty", severity: "error", message: "A 'BREAKING CHANGE:' footer must be followed by a description of the breaking change." });
5742
+ }
5743
+ }
5744
+ parsed.breaking = breaking;
5745
+ const valid = !findings.some((f) => f.severity === "error");
5746
+ return { message, valid, parsed, findings };
5747
+ }
5748
+ server.tool(
5749
+ "lint_conventional_commit",
5750
+ "Lint a commit message against the Conventional Commits v1.0.0 spec: header format (type(scope)!: description), blank-line separation before the body/footers, footer token format, and BREAKING CHANGE casing. Returns the parsed type/scope/description/body/footers plus a list of error and warning findings.",
5751
+ {
5752
+ message: z.string().describe("The full commit message: header, optional blank line, optional body, optional footers"),
5753
+ types: z.array(z.string()).optional().describe("Override the recognized type list (default: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test). Unrecognized types are a warning, not an error."),
5754
+ maxHeaderLength: z.number().optional().describe("Header length that triggers a warning (default 100)")
5755
+ },
5756
+ async ({ message, types, maxHeaderLength }) => {
5757
+ const result = _lintConventionalCommitMcp(message, types, maxHeaderLength);
5758
+ if ("error" in result) return errText(result);
5759
+ return text(JSON.stringify(result, null, 2));
5760
+ }
5761
+ );
5762
+ function _calculateSavingsGoalMcp(mode, goalAmount, currentSavings, annualInterestRatePercent, monthlyContribution, months) {
5763
+ if (mode !== "time-to-goal" && mode !== "contribution-for-target") {
5764
+ return { error: "mode must be 'time-to-goal' or 'contribution-for-target'" };
5765
+ }
5766
+ if (!Number.isFinite(goalAmount) || goalAmount <= 0) return { error: "goalAmount must be a finite number greater than zero" };
5767
+ if (!Number.isFinite(currentSavings) || currentSavings < 0) return { error: "currentSavings must be a finite, non-negative number" };
5768
+ if (!Number.isFinite(annualInterestRatePercent) || annualInterestRatePercent < 0) {
5769
+ return { error: "annualInterestRatePercent must be a finite, non-negative number" };
5770
+ }
5771
+ const round4 = (n) => Math.round(n * 1e4) / 1e4;
5772
+ const r = annualInterestRatePercent / 100 / 12;
5773
+ if (mode === "time-to-goal") {
5774
+ if (typeof monthlyContribution !== "number" || !Number.isFinite(monthlyContribution) || monthlyContribution < 0) {
5775
+ return { error: "monthlyContribution must be a finite, non-negative number" };
5776
+ }
5777
+ let exactMonths;
5778
+ if (currentSavings >= goalAmount) {
5779
+ exactMonths = 0;
5780
+ } else if (monthlyContribution === 0) {
5781
+ if (r === 0) return { error: "monthlyContribution must be greater than zero when annualInterestRatePercent is zero, since savings would never grow toward the goal" };
5782
+ if (currentSavings <= 0) return { error: "monthlyContribution must be greater than zero when currentSavings is zero, since there is nothing to compound" };
5783
+ exactMonths = Math.log(goalAmount / currentSavings) / Math.log(1 + r);
5784
+ } else if (r === 0) {
5785
+ exactMonths = (goalAmount - currentSavings) / monthlyContribution;
5786
+ } else {
5787
+ const denom = currentSavings + monthlyContribution / r;
5788
+ const x = (goalAmount + monthlyContribution / r) / denom;
5789
+ exactMonths = Math.log(x) / Math.log(1 + r);
5790
+ }
5791
+ const monthsRounded = Math.max(0, Math.ceil(exactMonths));
5792
+ const finalBalance = r === 0 ? currentSavings + monthlyContribution * monthsRounded : currentSavings * Math.pow(1 + r, monthsRounded) + monthlyContribution * ((Math.pow(1 + r, monthsRounded) - 1) / r);
5793
+ const totalContributed2 = monthlyContribution * monthsRounded;
5794
+ const totalInterestEarned2 = finalBalance - currentSavings - totalContributed2;
5795
+ return {
5796
+ mode,
5797
+ goalAmount: round4(goalAmount),
5798
+ currentSavings: round4(currentSavings),
5799
+ annualInterestRatePercent: round4(annualInterestRatePercent),
5800
+ monthlyContribution: round4(monthlyContribution),
5801
+ months: round4(exactMonths),
5802
+ monthsRounded,
5803
+ years: round4(monthsRounded / 12),
5804
+ totalContributed: round4(totalContributed2),
5805
+ totalInterestEarned: round4(totalInterestEarned2),
5806
+ finalBalance: round4(finalBalance)
5807
+ };
5808
+ }
5809
+ if (typeof months !== "number" || !Number.isFinite(months) || !Number.isInteger(months) || months <= 0) {
5810
+ return { error: "months must be a positive integer" };
5811
+ }
5812
+ const growthOfCurrentSavings = r === 0 ? currentSavings : currentSavings * Math.pow(1 + r, months);
5813
+ let requiredMonthlyContribution;
5814
+ if (growthOfCurrentSavings >= goalAmount) {
5815
+ requiredMonthlyContribution = 0;
5816
+ } else if (r === 0) {
5817
+ requiredMonthlyContribution = (goalAmount - currentSavings) / months;
5818
+ } else {
5819
+ requiredMonthlyContribution = (goalAmount - growthOfCurrentSavings) * r / (Math.pow(1 + r, months) - 1);
5820
+ }
5821
+ const totalContributed = requiredMonthlyContribution * months;
5822
+ const totalInterestEarned = goalAmount - currentSavings - totalContributed;
5823
+ return {
5824
+ mode,
5825
+ goalAmount: round4(goalAmount),
5826
+ currentSavings: round4(currentSavings),
5827
+ annualInterestRatePercent: round4(annualInterestRatePercent),
5828
+ monthlyContribution: round4(requiredMonthlyContribution),
5829
+ months,
5830
+ monthsRounded: months,
5831
+ years: round4(months / 12),
5832
+ totalContributed: round4(totalContributed),
5833
+ totalInterestEarned: round4(totalInterestEarned),
5834
+ finalBalance: round4(goalAmount)
5835
+ };
5836
+ }
5837
+ server.tool(
5838
+ "calculate_savings_goal",
5839
+ 'Standard time-value-of-money arithmetic for an ordinary annuity (contributions at the end of each month, compounded monthly). Mode "time-to-goal" solves for how many months it takes to reach a savings goal given a fixed monthly contribution; mode "contribution-for-target" solves for the monthly contribution required to reach the goal in a given number of months. Pure closed-form formulas, no live rate data.',
5840
+ {
5841
+ mode: z.enum(["time-to-goal", "contribution-for-target"]).describe("Which quantity to solve for"),
5842
+ goalAmount: z.number().describe("The target savings amount (must be greater than zero)"),
5843
+ currentSavings: z.number().describe("Current savings balance (cannot be negative)"),
5844
+ annualInterestRatePercent: z.number().describe("Nominal annual interest rate as a percent, compounded monthly (cannot be negative)"),
5845
+ monthlyContribution: z.number().optional().describe('Required for mode "time-to-goal": the fixed amount contributed each month'),
5846
+ months: z.number().optional().describe('Required for mode "contribution-for-target": the target number of months (positive integer)')
5847
+ },
5848
+ async ({ mode, goalAmount, currentSavings, annualInterestRatePercent, monthlyContribution, months }) => {
5849
+ const result = _calculateSavingsGoalMcp(mode, goalAmount, currentSavings, annualInterestRatePercent, monthlyContribution, months);
5850
+ if ("error" in result) return errText(result);
5851
+ return text(JSON.stringify(result, null, 2));
5852
+ }
5853
+ );
5549
5854
  var transport = new StdioServerTransport();
5550
5855
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.32.0",
4
- "description": "MCP server exposing 143 Utilix developer tools to Claude, Cursor, VS Code Copilot, and any MCP-compatible AI assistant.",
3
+ "version": "0.34.0",
4
+ "description": "MCP server exposing 152 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",