@utilix-tech/mcp 0.33.0 → 0.35.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.35.0
6
+ - Add `build_referrer_policy` tool: build a Referrer-Policy header value from one or more policy tokens with a plain-English explanation of each and warnings for risky choices like unsafe-url.
7
+ - Add `generate_markdown_toc` tool: scan a Markdown document's ATX headings and build a nested table of contents with GitHub-style anchor slugs, disambiguating duplicates.
8
+
9
+ ## 0.34.0
10
+ - 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.
11
+ - 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.
12
+
5
13
  ## 0.33.0
6
14
  - 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.
7
15
  - Add `format_few_shot_examples` tool: format input/output example pairs into a numbered, XML-tagged, or delimiter-separated few-shot prompt template.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 152 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
3
+ MCP server exposing 156 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
 
@@ -156,7 +156,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
156
156
  | `convert_timezone` | Convert a date/time between timezones |
157
157
  | `convert_iso8601_duration` | Parse an ISO 8601 duration into components and total seconds, or format seconds into a duration string |
158
158
 
159
- ### Units (21 tools)
159
+ ### Units (22 tools)
160
160
  | Tool | Description |
161
161
  |------|-------------|
162
162
  | `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
@@ -180,8 +180,9 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
180
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 |
181
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 |
182
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 |
183
184
 
184
- ### API / Network (12 tools)
185
+ ### API / Network (13 tools)
185
186
  | Tool | Description |
186
187
  |------|-------------|
187
188
  | `decode_jwt` | Decode a JWT: header, payload, and expiry |
@@ -195,6 +196,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
195
196
  | `validate_robots_txt` | Validate a robots.txt file: User-agent groups, rules, and directive syntax |
196
197
  | `parse_user_agent` | Parse a browser User-Agent string into browser, engine, OS, and device type |
197
198
  | `build_cache_control` | Build and explain an HTTP Cache-Control response header from directives (max-age, no-store, immutable, etc.) |
199
+ | `build_referrer_policy` | Build and explain an HTTP Referrer-Policy header from one or more fallback-chain policy tokens |
198
200
  | `parse_ipv6_address` | Expand an IPv6 address to its full 8-group form or compress it to its RFC 5952 canonical shortest form, classify its address type, and handle an embedded IPv4 suffix or zone ID |
199
201
 
200
202
  ### Color (4 tools)
@@ -205,7 +207,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
205
207
  | `color_palette` | Generate a color palette from a base color |
206
208
  | `color_shades` | Generate shades or tints from a base color |
207
209
 
208
- ### Code (10 tools)
210
+ ### Code (11 tools)
209
211
  | Tool | Description |
210
212
  |------|-------------|
211
213
  | `format_sql` | Format a SQL query with proper indentation |
@@ -218,8 +220,9 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
218
220
  | `lookup_mime` | Look up the MIME type for a file extension, or the extension(s) for a MIME type |
219
221
  | `parse_github_url` | Parse a github.com URL (repo, pull request, issue, commit, file, or release) into structured components |
220
222
  | `detect_redos` | Statically flag catastrophic-backtracking (ReDoS) risk in a regex: nested quantifiers, overlapping alternation, adjacent overlapping quantifiers |
223
+ | `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 |
221
224
 
222
- ### Data (7 tools)
225
+ ### Data (8 tools)
223
226
  | Tool | Description |
224
227
  |------|-------------|
225
228
  | `validate_yaml` | Validate YAML syntax |
@@ -229,6 +232,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
229
232
  | `format_ndjson` | Validate and pretty-print newline-delimited JSON (NDJSON) |
230
233
  | `diff_env_example` | Diff a .env file against its .env.example template: flags keys missing from either side, plus empty placeholder values |
231
234
  | `parse_markdown_front_matter` | Split a Markdown document into its YAML front matter fields and body content |
235
+ | `generate_markdown_toc` | Scan a Markdown document's ATX headings and build a nested table of contents with GitHub-style anchor slugs |
232
236
 
233
237
  ### CSS (2 tools)
234
238
  | Tool | Description |
package/dist/index.js CHANGED
@@ -4157,6 +4157,66 @@ server.tool(
4157
4157
  return text(JSON.stringify(result, null, 2));
4158
4158
  }
4159
4159
  );
4160
+ var _REFERRER_POLICY_VALUES_MCP = [
4161
+ "no-referrer",
4162
+ "no-referrer-when-downgrade",
4163
+ "origin",
4164
+ "origin-when-cross-origin",
4165
+ "same-origin",
4166
+ "strict-origin",
4167
+ "strict-origin-when-cross-origin",
4168
+ "unsafe-url"
4169
+ ];
4170
+ var _REFERRER_POLICY_INFO_MCP = {
4171
+ "no-referrer": { explanation: "Never sends the Referer header, on any request. The strictest option; the destination learns nothing about where the request came from.", leaksPathAndQuery: false, sentOnDowngrade: false },
4172
+ "no-referrer-when-downgrade": { explanation: "Sends the full URL (origin, path, and query string) for same-security-level or upgrading requests, but omits the header entirely when navigating from HTTPS to HTTP. This was the legacy browser default before strict-origin-when-cross-origin.", leaksPathAndQuery: true, sentOnDowngrade: false },
4173
+ origin: { explanation: "Sends only the origin (scheme, host, and port) on every request, same-origin or cross-origin, including on a downgrade. The path and query string are always stripped.", leaksPathAndQuery: false, sentOnDowngrade: true },
4174
+ "origin-when-cross-origin": { explanation: "Sends the full URL for same-origin requests, but only the origin for cross-origin requests. Still sends the origin on a downgrade.", leaksPathAndQuery: true, sentOnDowngrade: true },
4175
+ "same-origin": { explanation: "Sends the full URL for same-origin requests, and no Referer header at all for cross-origin requests.", leaksPathAndQuery: true, sentOnDowngrade: false },
4176
+ "strict-origin": { explanation: "Sends only the origin, and only when the security level stays the same or improves (HTTPS to HTTPS, or HTTP to anything). Nothing is sent on a downgrade from HTTPS to HTTP.", leaksPathAndQuery: false, sentOnDowngrade: false },
4177
+ "strict-origin-when-cross-origin": { explanation: "Sends the full URL for same-origin requests, only the origin for cross-origin requests at the same or better security level, and nothing at all on a downgrade. This is the current browser default when no policy is set.", leaksPathAndQuery: true, sentOnDowngrade: false },
4178
+ "unsafe-url": { explanation: "Always sends the full URL, including path and query string, on every request, even on a downgrade from HTTPS to HTTP. Generally discouraged: query strings can carry session tokens or other sensitive data that then leaks to third-party destinations and to the network on an insecure connection.", leaksPathAndQuery: true, sentOnDowngrade: true }
4179
+ };
4180
+ function _buildReferrerPolicyMcp(policies) {
4181
+ const trimmed = policies.map((p) => p.trim()).filter((p) => p !== "");
4182
+ if (trimmed.length === 0) return { error: "At least one policy value is required" };
4183
+ const entries = [];
4184
+ for (const value of trimmed) {
4185
+ if (!_REFERRER_POLICY_VALUES_MCP.includes(value)) {
4186
+ return { error: `'${value}' is not a valid Referrer-Policy value. Valid values: ${_REFERRER_POLICY_VALUES_MCP.join(", ")}` };
4187
+ }
4188
+ entries.push({ value, ..._REFERRER_POLICY_INFO_MCP[value] });
4189
+ }
4190
+ const warnings = [];
4191
+ const last = entries[entries.length - 1];
4192
+ if (last.value === "unsafe-url") {
4193
+ warnings.push("unsafe-url sends the full URL, including query strings, on every request, even over an insecure downgrade. Prefer strict-origin-when-cross-origin unless you have a specific reason to leak full URLs.");
4194
+ }
4195
+ if (entries.length > 1 && entries.some((e, i) => i !== entries.length - 1 && e.value === last.value)) {
4196
+ warnings.push("The same policy value appears more than once in the fallback chain, which has no effect beyond the first occurrence.");
4197
+ }
4198
+ const header = entries.map((e) => e.value).join(", ");
4199
+ return {
4200
+ header,
4201
+ headerName: "Referrer-Policy",
4202
+ snippet: `Referrer-Policy: ${header}`,
4203
+ metaTag: `<meta name="referrer" content="${last.value}">`,
4204
+ policies: entries,
4205
+ warnings
4206
+ };
4207
+ }
4208
+ server.tool(
4209
+ "build_referrer_policy",
4210
+ "Build an HTTP Referrer-Policy response-header value from one or more policy tokens (a fallback chain per the Referrer Policy spec), with a plain-English explanation of each token and warnings for risky choices like unsafe-url. Pure string assembly and lookup, not validated against live request traffic.",
4211
+ {
4212
+ policies: z.array(z.enum(_REFERRER_POLICY_VALUES_MCP)).min(1).describe('One or more Referrer-Policy tokens forming a fallback chain, e.g. ["strict-origin-when-cross-origin"]')
4213
+ },
4214
+ async ({ policies }) => {
4215
+ const result = _buildReferrerPolicyMcp(policies);
4216
+ if ("error" in result) return errText(result);
4217
+ return text(JSON.stringify(result, null, 2));
4218
+ }
4219
+ );
4160
4220
  function _isValidIpv6HexGroup(g) {
4161
4221
  return /^[0-9a-fA-F]{1,4}$/.test(g);
4162
4222
  }
@@ -5638,5 +5698,288 @@ server.tool(
5638
5698
  return text(JSON.stringify(result, null, 2));
5639
5699
  }
5640
5700
  );
5701
+ function _stripInlineMarkdownMcp(text2) {
5702
+ return text2.replace(/(\*\*\*|___)(.+?)\1/g, "$2").replace(/(\*\*|__)(.+?)\1/g, "$2").replace(/(\*|_)(.+?)\1/g, "$2").replace(/`([^`]+)`/g, "$1").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").trim();
5703
+ }
5704
+ function _slugifyHeadingMcp(text2, seen) {
5705
+ let slug = text2.toLowerCase().trim().replace(/[^\p{L}\p{N}\s_-]/gu, "").replace(/\s/g, "-");
5706
+ const count = seen.get(slug) ?? 0;
5707
+ seen.set(slug, count + 1);
5708
+ if (count > 0) slug = `${slug}-${count}`;
5709
+ return slug;
5710
+ }
5711
+ function _generateMarkdownTocMcp(markdown, minLevel, maxLevel, ordered) {
5712
+ if (typeof markdown !== "string" || markdown.trim() === "") return { error: "markdown is required" };
5713
+ if (!Number.isInteger(minLevel) || !Number.isInteger(maxLevel) || minLevel < 1 || minLevel > 6 || maxLevel < 1 || maxLevel > 6 || minLevel > maxLevel) {
5714
+ return { error: "minLevel and maxLevel must be integers between 1 and 6, with minLevel <= maxLevel" };
5715
+ }
5716
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
5717
+ const entries = [];
5718
+ const seen = /* @__PURE__ */ new Map();
5719
+ let inFence = false;
5720
+ let fenceMarker = "";
5721
+ for (const line of lines) {
5722
+ const fenceMatch = line.match(/^\s*(```+|~~~+)/);
5723
+ if (fenceMatch) {
5724
+ if (!inFence) {
5725
+ inFence = true;
5726
+ fenceMarker = fenceMatch[1][0];
5727
+ } else if (fenceMatch[1][0] === fenceMarker) {
5728
+ inFence = false;
5729
+ }
5730
+ continue;
5731
+ }
5732
+ if (inFence) continue;
5733
+ const headingMatch = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
5734
+ if (!headingMatch) continue;
5735
+ const level = headingMatch[1].length;
5736
+ const text2 = _stripInlineMarkdownMcp(headingMatch[2]);
5737
+ if (text2 === "") continue;
5738
+ const slug = _slugifyHeadingMcp(text2, seen);
5739
+ if (level < minLevel || level > maxLevel) continue;
5740
+ entries.push({ level, text: text2, slug });
5741
+ }
5742
+ if (entries.length === 0) return { error: "No headings found in the given level range" };
5743
+ const baseLevel = Math.min(...entries.map((e) => e.level));
5744
+ const counters = [0, 0, 0, 0, 0, 0, 0];
5745
+ const tocLines = entries.map((e) => {
5746
+ const indent = " ".repeat(e.level - baseLevel);
5747
+ if (ordered) {
5748
+ counters[e.level] = (counters[e.level] ?? 0) + 1;
5749
+ for (let l = e.level + 1; l < counters.length; l++) counters[l] = 0;
5750
+ return `${indent}${counters[e.level]}. [${e.text}](#${e.slug})`;
5751
+ }
5752
+ return `${indent}- [${e.text}](#${e.slug})`;
5753
+ });
5754
+ return { toc: tocLines.join("\n"), entries };
5755
+ }
5756
+ server.tool(
5757
+ "generate_markdown_toc",
5758
+ "Scan a Markdown document's ATX headings (# through ######) and build a nested table of contents with GitHub-style anchor slugs, disambiguating duplicate headings with -1, -2, ... suffixes. Setext-style headings are not recognized, and headings inside fenced code blocks are ignored.",
5759
+ {
5760
+ markdown: z.string().describe("The Markdown document to scan for headings"),
5761
+ minLevel: z.number().int().min(1).max(6).optional().describe("Shallowest heading level to include, defaults to 1"),
5762
+ maxLevel: z.number().int().min(1).max(6).optional().describe("Deepest heading level to include, defaults to 6"),
5763
+ ordered: z.boolean().optional().describe("Render as a numbered list instead of a bullet list, defaults to false")
5764
+ },
5765
+ async ({ markdown, minLevel, maxLevel, ordered }) => {
5766
+ const result = _generateMarkdownTocMcp(markdown, minLevel ?? 1, maxLevel ?? 6, ordered ?? false);
5767
+ if ("error" in result) return errText(result);
5768
+ return text(JSON.stringify(result, null, 2));
5769
+ }
5770
+ );
5771
+ var _COMMIT_DEFAULT_TYPES_MCP = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test"];
5772
+ var _COMMIT_HEADER_RE_MCP = /^([A-Za-z][A-Za-z0-9-]*)(\(([^()]*)\))?(!)?: (.+)$/;
5773
+ var _COMMIT_FOOTER_LINE_RE_MCP = /^([A-Za-z][A-Za-z0-9-]*|BREAKING CHANGE)(: ?| #)(.*)$/;
5774
+ var _COMMIT_BREAKING_LOOKALIKE_RE_MCP = /^([A-Za-z][A-Za-z -]*?)(: | #)(.*)$/;
5775
+ var _COMMIT_BREAKING_TOKEN_RE_MCP = /^breaking[- ]change$/i;
5776
+ function _lintConventionalCommitMcp(message, types, maxHeaderLength) {
5777
+ if (typeof message !== "string" || message.trim() === "") return { error: "message is required" };
5778
+ if (message.length > 2e4) return { error: "message too long (max 20,000 chars)" };
5779
+ const commitTypes = types && types.length > 0 ? types : _COMMIT_DEFAULT_TYPES_MCP;
5780
+ const headerLimit = maxHeaderLength ?? 100;
5781
+ const findings = [];
5782
+ const lines = message.replace(/\r\n/g, "\n").split("\n");
5783
+ const header = lines[0];
5784
+ const match = header.match(_COMMIT_HEADER_RE_MCP);
5785
+ const parsed = {
5786
+ header,
5787
+ type: null,
5788
+ scope: null,
5789
+ breaking: false,
5790
+ description: null,
5791
+ body: null,
5792
+ footers: []
5793
+ };
5794
+ if (!match) {
5795
+ 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." });
5796
+ return { message, valid: false, parsed, findings };
5797
+ }
5798
+ const [, type, scopeGroup, scope, bang, description] = match;
5799
+ parsed.type = type;
5800
+ if (scopeGroup !== void 0) {
5801
+ const scopeContent = (scope ?? "").trim();
5802
+ if (scopeContent === "") {
5803
+ 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):." });
5804
+ } else {
5805
+ parsed.scope = scopeContent;
5806
+ }
5807
+ }
5808
+ const trimmedDescription = description.trim();
5809
+ if (trimmedDescription === "") {
5810
+ findings.push({ rule: "subject-empty", severity: "error", message: "The description after the colon must not be empty." });
5811
+ } else {
5812
+ parsed.description = trimmedDescription;
5813
+ if (trimmedDescription.endsWith(".")) {
5814
+ findings.push({ rule: "subject-full-stop", severity: "warning", message: "The description should not end with a period." });
5815
+ }
5816
+ }
5817
+ if (!commitTypes.includes(type.toLowerCase())) {
5818
+ 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.` });
5819
+ }
5820
+ if (type !== type.toLowerCase()) {
5821
+ 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." });
5822
+ }
5823
+ if (header.length > headerLimit) {
5824
+ findings.push({ rule: "header-max-length", severity: "warning", message: `Header is ${header.length} characters, longer than the recommended maximum of ${headerLimit}.` });
5825
+ }
5826
+ let breaking = bang === "!";
5827
+ if (lines.length > 1) {
5828
+ if (lines[1].trim() !== "") {
5829
+ findings.push({ rule: "missing-blank-line-before-body", severity: "error", message: "A blank line must separate the header from the body or footer." });
5830
+ }
5831
+ const rest = lines.slice(1).join("\n");
5832
+ const paragraphs = rest.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p !== "");
5833
+ let footerParagraph = null;
5834
+ if (paragraphs.length > 0) {
5835
+ const last = paragraphs[paragraphs.length - 1];
5836
+ const lastLines = last.split("\n");
5837
+ if (lastLines.every((l) => _COMMIT_FOOTER_LINE_RE_MCP.test(l.trim()))) {
5838
+ footerParagraph = last;
5839
+ paragraphs.pop();
5840
+ }
5841
+ }
5842
+ parsed.body = paragraphs.length > 0 ? paragraphs.join("\n\n") : null;
5843
+ const footers2 = parsed.footers;
5844
+ if (footerParagraph) {
5845
+ for (const line of footerParagraph.split("\n")) {
5846
+ const fm = line.trim().match(_COMMIT_FOOTER_LINE_RE_MCP);
5847
+ if (!fm) continue;
5848
+ const [, token, , value] = fm;
5849
+ footers2.push({ token, value: value.trim() });
5850
+ }
5851
+ }
5852
+ for (const f of footers2) {
5853
+ if (_COMMIT_BREAKING_TOKEN_RE_MCP.test(f.token) && f.token !== "BREAKING CHANGE" && f.token !== "BREAKING-CHANGE") {
5854
+ 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).` });
5855
+ }
5856
+ }
5857
+ for (const p of paragraphs) {
5858
+ for (const line of p.split("\n")) {
5859
+ const bm = line.trim().match(_COMMIT_BREAKING_LOOKALIKE_RE_MCP);
5860
+ if (bm && _COMMIT_BREAKING_TOKEN_RE_MCP.test(bm[1]) && bm[1] !== "BREAKING CHANGE") {
5861
+ 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 ': '.` });
5862
+ }
5863
+ }
5864
+ }
5865
+ }
5866
+ const footers = parsed.footers;
5867
+ const breakingFooter = footers.find((f) => f.token === "BREAKING CHANGE" || f.token === "BREAKING-CHANGE");
5868
+ if (breakingFooter) {
5869
+ breaking = true;
5870
+ if (breakingFooter.value === "") {
5871
+ findings.push({ rule: "breaking-change-empty", severity: "error", message: "A 'BREAKING CHANGE:' footer must be followed by a description of the breaking change." });
5872
+ }
5873
+ }
5874
+ parsed.breaking = breaking;
5875
+ const valid = !findings.some((f) => f.severity === "error");
5876
+ return { message, valid, parsed, findings };
5877
+ }
5878
+ server.tool(
5879
+ "lint_conventional_commit",
5880
+ "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.",
5881
+ {
5882
+ message: z.string().describe("The full commit message: header, optional blank line, optional body, optional footers"),
5883
+ 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."),
5884
+ maxHeaderLength: z.number().optional().describe("Header length that triggers a warning (default 100)")
5885
+ },
5886
+ async ({ message, types, maxHeaderLength }) => {
5887
+ const result = _lintConventionalCommitMcp(message, types, maxHeaderLength);
5888
+ if ("error" in result) return errText(result);
5889
+ return text(JSON.stringify(result, null, 2));
5890
+ }
5891
+ );
5892
+ function _calculateSavingsGoalMcp(mode, goalAmount, currentSavings, annualInterestRatePercent, monthlyContribution, months) {
5893
+ if (mode !== "time-to-goal" && mode !== "contribution-for-target") {
5894
+ return { error: "mode must be 'time-to-goal' or 'contribution-for-target'" };
5895
+ }
5896
+ if (!Number.isFinite(goalAmount) || goalAmount <= 0) return { error: "goalAmount must be a finite number greater than zero" };
5897
+ if (!Number.isFinite(currentSavings) || currentSavings < 0) return { error: "currentSavings must be a finite, non-negative number" };
5898
+ if (!Number.isFinite(annualInterestRatePercent) || annualInterestRatePercent < 0) {
5899
+ return { error: "annualInterestRatePercent must be a finite, non-negative number" };
5900
+ }
5901
+ const round4 = (n) => Math.round(n * 1e4) / 1e4;
5902
+ const r = annualInterestRatePercent / 100 / 12;
5903
+ if (mode === "time-to-goal") {
5904
+ if (typeof monthlyContribution !== "number" || !Number.isFinite(monthlyContribution) || monthlyContribution < 0) {
5905
+ return { error: "monthlyContribution must be a finite, non-negative number" };
5906
+ }
5907
+ let exactMonths;
5908
+ if (currentSavings >= goalAmount) {
5909
+ exactMonths = 0;
5910
+ } else if (monthlyContribution === 0) {
5911
+ if (r === 0) return { error: "monthlyContribution must be greater than zero when annualInterestRatePercent is zero, since savings would never grow toward the goal" };
5912
+ if (currentSavings <= 0) return { error: "monthlyContribution must be greater than zero when currentSavings is zero, since there is nothing to compound" };
5913
+ exactMonths = Math.log(goalAmount / currentSavings) / Math.log(1 + r);
5914
+ } else if (r === 0) {
5915
+ exactMonths = (goalAmount - currentSavings) / monthlyContribution;
5916
+ } else {
5917
+ const denom = currentSavings + monthlyContribution / r;
5918
+ const x = (goalAmount + monthlyContribution / r) / denom;
5919
+ exactMonths = Math.log(x) / Math.log(1 + r);
5920
+ }
5921
+ const monthsRounded = Math.max(0, Math.ceil(exactMonths));
5922
+ const finalBalance = r === 0 ? currentSavings + monthlyContribution * monthsRounded : currentSavings * Math.pow(1 + r, monthsRounded) + monthlyContribution * ((Math.pow(1 + r, monthsRounded) - 1) / r);
5923
+ const totalContributed2 = monthlyContribution * monthsRounded;
5924
+ const totalInterestEarned2 = finalBalance - currentSavings - totalContributed2;
5925
+ return {
5926
+ mode,
5927
+ goalAmount: round4(goalAmount),
5928
+ currentSavings: round4(currentSavings),
5929
+ annualInterestRatePercent: round4(annualInterestRatePercent),
5930
+ monthlyContribution: round4(monthlyContribution),
5931
+ months: round4(exactMonths),
5932
+ monthsRounded,
5933
+ years: round4(monthsRounded / 12),
5934
+ totalContributed: round4(totalContributed2),
5935
+ totalInterestEarned: round4(totalInterestEarned2),
5936
+ finalBalance: round4(finalBalance)
5937
+ };
5938
+ }
5939
+ if (typeof months !== "number" || !Number.isFinite(months) || !Number.isInteger(months) || months <= 0) {
5940
+ return { error: "months must be a positive integer" };
5941
+ }
5942
+ const growthOfCurrentSavings = r === 0 ? currentSavings : currentSavings * Math.pow(1 + r, months);
5943
+ let requiredMonthlyContribution;
5944
+ if (growthOfCurrentSavings >= goalAmount) {
5945
+ requiredMonthlyContribution = 0;
5946
+ } else if (r === 0) {
5947
+ requiredMonthlyContribution = (goalAmount - currentSavings) / months;
5948
+ } else {
5949
+ requiredMonthlyContribution = (goalAmount - growthOfCurrentSavings) * r / (Math.pow(1 + r, months) - 1);
5950
+ }
5951
+ const totalContributed = requiredMonthlyContribution * months;
5952
+ const totalInterestEarned = goalAmount - currentSavings - totalContributed;
5953
+ return {
5954
+ mode,
5955
+ goalAmount: round4(goalAmount),
5956
+ currentSavings: round4(currentSavings),
5957
+ annualInterestRatePercent: round4(annualInterestRatePercent),
5958
+ monthlyContribution: round4(requiredMonthlyContribution),
5959
+ months,
5960
+ monthsRounded: months,
5961
+ years: round4(months / 12),
5962
+ totalContributed: round4(totalContributed),
5963
+ totalInterestEarned: round4(totalInterestEarned),
5964
+ finalBalance: round4(goalAmount)
5965
+ };
5966
+ }
5967
+ server.tool(
5968
+ "calculate_savings_goal",
5969
+ '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.',
5970
+ {
5971
+ mode: z.enum(["time-to-goal", "contribution-for-target"]).describe("Which quantity to solve for"),
5972
+ goalAmount: z.number().describe("The target savings amount (must be greater than zero)"),
5973
+ currentSavings: z.number().describe("Current savings balance (cannot be negative)"),
5974
+ annualInterestRatePercent: z.number().describe("Nominal annual interest rate as a percent, compounded monthly (cannot be negative)"),
5975
+ monthlyContribution: z.number().optional().describe('Required for mode "time-to-goal": the fixed amount contributed each month'),
5976
+ months: z.number().optional().describe('Required for mode "contribution-for-target": the target number of months (positive integer)')
5977
+ },
5978
+ async ({ mode, goalAmount, currentSavings, annualInterestRatePercent, monthlyContribution, months }) => {
5979
+ const result = _calculateSavingsGoalMcp(mode, goalAmount, currentSavings, annualInterestRatePercent, monthlyContribution, months);
5980
+ if ("error" in result) return errText(result);
5981
+ return text(JSON.stringify(result, null, 2));
5982
+ }
5983
+ );
5641
5984
  var transport = new StdioServerTransport();
5642
5985
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
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",