@utilix-tech/mcp 0.37.0 → 0.39.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.39.0
6
+ - Add `compare_unit_prices` tool: compare two or more package options by price and quantity to find the true cost per unit, flag the cheapest, and report how much more expensive each other option is by percentage.
7
+ - Add `csv_to_sql_insert` tool: convert CSV data with a header row into parameterless SQL INSERT statements, with basic type inference (numbers/booleans unquoted, empty cells NULL) and ANSI/MySQL identifier quoting.
8
+
9
+ ## 0.38.0
10
+ - Add `check_set_cookie_header` tool: parse a Set-Cookie header value and validate its attribute combination, flagging SameSite=None missing Secure, __Host-/__Secure- prefix violations, a missing Secure attribute, and a Max-Age past 400 days.
11
+ - Add `test_gitignore_patterns` tool: test a list of paths against a .gitignore file's patterns, following git's last-match-wins and negation rules, and report which pattern and line number decided each path's outcome.
12
+
5
13
  ## 0.37.0
6
14
  - Add `build_link_header` tool: build an HTTP Link response header from URL/rel pairs (preload, preconnect, prefetch, canonical, pagination, etc.), including the as/type/crossorigin attributes preload needs, plus a plain-English explanation of each entry and warnings for common mistakes like a preload missing `as`.
7
15
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/mcp
2
2
 
3
- MCP server exposing 159 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
3
+ MCP server exposing 163 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
 
@@ -157,7 +157,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
157
157
  | `convert_timezone` | Convert a date/time between timezones |
158
158
  | `convert_iso8601_duration` | Parse an ISO 8601 duration into components and total seconds, or format seconds into a duration string |
159
159
 
160
- ### Units (23 tools)
160
+ ### Units (24 tools)
161
161
  | Tool | Description |
162
162
  |------|-------------|
163
163
  | `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
@@ -183,8 +183,9 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
183
183
  | `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 |
184
184
  | `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 |
185
185
  | `calculate_roi` | Compute return on investment percent from an initial and final value, plus the annualized (CAGR) return given an optional holding period in years |
186
+ | `compare_unit_prices` | Compare two or more package options by price and quantity to find the true cost per unit, flag the cheapest, and report how much more expensive each other option is |
186
187
 
187
- ### API / Network (14 tools)
188
+ ### API / Network (15 tools)
188
189
  | Tool | Description |
189
190
  |------|-------------|
190
191
  | `decode_jwt` | Decode a JWT: header, payload, and expiry |
@@ -201,6 +202,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
201
202
  | `build_referrer_policy` | Build and explain an HTTP Referrer-Policy header from one or more fallback-chain policy tokens |
202
203
  | `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 |
203
204
  | `build_link_header` | Build and explain an HTTP Link response header from URL/rel pairs (preload, preconnect, prefetch, canonical, pagination, etc.) |
205
+ | `check_set_cookie_header` | Parse a Set-Cookie header and validate its attribute combination: SameSite=None requires Secure, __Host-/__Secure- prefix rules, missing Secure, overly long Max-Age |
204
206
 
205
207
  ### Color (4 tools)
206
208
  | Tool | Description |
@@ -210,7 +212,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
210
212
  | `color_palette` | Generate a color palette from a base color |
211
213
  | `color_shades` | Generate shades or tints from a base color |
212
214
 
213
- ### Code (11 tools)
215
+ ### Code (12 tools)
214
216
  | Tool | Description |
215
217
  |------|-------------|
216
218
  | `format_sql` | Format a SQL query with proper indentation |
@@ -224,8 +226,9 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
224
226
  | `parse_github_url` | Parse a github.com URL (repo, pull request, issue, commit, file, or release) into structured components |
225
227
  | `detect_redos` | Statically flag catastrophic-backtracking (ReDoS) risk in a regex: nested quantifiers, overlapping alternation, adjacent overlapping quantifiers |
226
228
  | `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 |
229
+ | `test_gitignore_patterns` | Test a list of paths against a .gitignore file's patterns, following git's last-match-wins and negation rules |
227
230
 
228
- ### Data (8 tools)
231
+ ### Data (9 tools)
229
232
  | Tool | Description |
230
233
  |------|-------------|
231
234
  | `validate_yaml` | Validate YAML syntax |
@@ -236,6 +239,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
236
239
  | `diff_env_example` | Diff a .env file against its .env.example template: flags keys missing from either side, plus empty placeholder values |
237
240
  | `parse_markdown_front_matter` | Split a Markdown document into its YAML front matter fields and body content |
238
241
  | `generate_markdown_toc` | Scan a Markdown document's ATX headings and build a nested table of contents with GitHub-style anchor slugs |
242
+ | `csv_to_sql_insert` | Convert CSV data with a header row into parameterless SQL INSERT statements, with basic type inference and ANSI/MySQL identifier quoting |
239
243
 
240
244
  ### CSS (2 tools)
241
245
  | Tool | Description |
package/dist/index.js CHANGED
@@ -6421,5 +6421,299 @@ server.tool(
6421
6421
  return text(JSON.stringify(result, null, 2));
6422
6422
  }
6423
6423
  );
6424
+ var _MAX_COOKIE_AGE_SECONDS_MCP = 400 * 24 * 60 * 60;
6425
+ function _checkSetCookieHeaderMcp(header) {
6426
+ if (!header.trim()) return { error: "A Set-Cookie header value is required" };
6427
+ const parts = header.split(";").map((p) => p.trim()).filter(Boolean);
6428
+ const [nameValue, ...attrParts] = parts;
6429
+ const eqIdx = nameValue.indexOf("=");
6430
+ if (eqIdx === -1) return { error: "Missing name=value pair" };
6431
+ const name = nameValue.slice(0, eqIdx).trim();
6432
+ const value = nameValue.slice(eqIdx + 1).trim();
6433
+ if (!name) return { error: "Cookie name cannot be empty" };
6434
+ const cookie = {
6435
+ name,
6436
+ value,
6437
+ domain: null,
6438
+ path: null,
6439
+ expires: null,
6440
+ maxAge: null,
6441
+ secure: false,
6442
+ httpOnly: false,
6443
+ sameSite: null
6444
+ };
6445
+ for (const attr of attrParts) {
6446
+ const idx = attr.indexOf("=");
6447
+ const key = (idx === -1 ? attr : attr.slice(0, idx)).trim().toLowerCase();
6448
+ const val = idx === -1 ? "" : attr.slice(idx + 1).trim();
6449
+ switch (key) {
6450
+ case "domain":
6451
+ cookie.domain = val || null;
6452
+ break;
6453
+ case "path":
6454
+ cookie.path = val || null;
6455
+ break;
6456
+ case "expires":
6457
+ cookie.expires = val || null;
6458
+ break;
6459
+ case "max-age": {
6460
+ const n = Number(val);
6461
+ cookie.maxAge = Number.isFinite(n) ? n : null;
6462
+ break;
6463
+ }
6464
+ case "secure":
6465
+ cookie.secure = true;
6466
+ break;
6467
+ case "httponly":
6468
+ cookie.httpOnly = true;
6469
+ break;
6470
+ case "samesite": {
6471
+ const normalized = val.toLowerCase();
6472
+ if (normalized === "strict") cookie.sameSite = "Strict";
6473
+ else if (normalized === "lax") cookie.sameSite = "Lax";
6474
+ else if (normalized === "none") cookie.sameSite = "None";
6475
+ break;
6476
+ }
6477
+ }
6478
+ }
6479
+ const issues = [];
6480
+ const warnings = [];
6481
+ if (cookie.sameSite === "None" && !cookie.secure) {
6482
+ issues.push("SameSite=None requires the Secure attribute \u2014 modern browsers reject this cookie without it.");
6483
+ }
6484
+ if (name.startsWith("__Host-")) {
6485
+ if (!cookie.secure) issues.push("__Host- prefixed cookies require the Secure attribute.");
6486
+ if (cookie.domain) issues.push("__Host- prefixed cookies must not set a Domain attribute.");
6487
+ if (cookie.path !== "/") issues.push("__Host- prefixed cookies must set Path=/.");
6488
+ } else if (name.startsWith("__Secure-")) {
6489
+ if (!cookie.secure) issues.push("__Secure- prefixed cookies require the Secure attribute.");
6490
+ }
6491
+ if (!cookie.secure) {
6492
+ warnings.push("Missing Secure attribute \u2014 the cookie can be sent over a plain HTTP connection.");
6493
+ }
6494
+ if (cookie.maxAge !== null && cookie.maxAge > _MAX_COOKIE_AGE_SECONDS_MCP) {
6495
+ warnings.push(`Max-Age (${cookie.maxAge}s) exceeds 400 days \u2014 Chromium-based browsers clamp cookie expiry to 400 days from when it's set, so this will be shortened.`);
6496
+ }
6497
+ return { cookie, issues, warnings, valid: issues.length === 0 };
6498
+ }
6499
+ server.tool(
6500
+ "check_set_cookie_header",
6501
+ "Parse a Set-Cookie header value and validate its attribute combination: SameSite=None requires Secure, the __Host-/__Secure- name prefixes each require their own attributes (__Host- additionally forbids Domain and requires Path=/), a missing Secure attribute is flagged, and a Max-Age beyond 400 days is flagged since Chromium-based browsers clamp it to that cap.",
6502
+ {
6503
+ header: z.string().describe('The Set-Cookie header value to parse and validate, e.g. "session=abc123; Secure; SameSite=None"')
6504
+ },
6505
+ async ({ header }) => {
6506
+ const result = _checkSetCookieHeaderMcp(header);
6507
+ if ("error" in result) return errText(result);
6508
+ return text(JSON.stringify(result, null, 2));
6509
+ }
6510
+ );
6511
+ function _globSegmentToRegexSourceMcp(segment) {
6512
+ let out = "";
6513
+ for (let i = 0; i < segment.length; i++) {
6514
+ const c = segment[i];
6515
+ if (c === "*") {
6516
+ out += "[^/]*";
6517
+ } else if (c === "?") {
6518
+ out += "[^/]";
6519
+ } else if (c === "[") {
6520
+ const close = segment.indexOf("]", i + 1);
6521
+ if (close === -1) {
6522
+ out += "\\[";
6523
+ } else {
6524
+ let cls = segment.slice(i + 1, close);
6525
+ if (cls.startsWith("!")) cls = "^" + cls.slice(1);
6526
+ out += `[${cls}]`;
6527
+ i = close;
6528
+ }
6529
+ } else {
6530
+ out += c.replace(/[.+^${}()|\\]/g, "\\$&");
6531
+ }
6532
+ }
6533
+ return out;
6534
+ }
6535
+ function _compileGitignorePatternMcp(raw, line) {
6536
+ let pattern = raw.replace(/\s+$/, "");
6537
+ if (!pattern || pattern.startsWith("#")) return null;
6538
+ let negate = false;
6539
+ if (pattern.startsWith("!")) {
6540
+ negate = true;
6541
+ pattern = pattern.slice(1);
6542
+ }
6543
+ pattern = pattern.replace(/\\(.)/g, "$1");
6544
+ if (!pattern) return null;
6545
+ const dirOnly = pattern.endsWith("/") && !pattern.endsWith("\\/");
6546
+ if (dirOnly) pattern = pattern.slice(0, -1);
6547
+ const hasLeadingSlash = pattern.startsWith("/");
6548
+ if (hasLeadingSlash) pattern = pattern.slice(1);
6549
+ const anchored = hasLeadingSlash || pattern.includes("/");
6550
+ const segments = pattern.split("/");
6551
+ const regexParts = segments.map((seg) => {
6552
+ if (seg === "**") return "(?:.*)";
6553
+ return _globSegmentToRegexSourceMcp(seg);
6554
+ });
6555
+ let body = regexParts.join("/");
6556
+ body = body.replace(/\(\?:\.\*\)\//g, "(?:.*/)?").replace(/\/\(\?:\.\*\)/g, "(?:/.*)?");
6557
+ const prefix = anchored ? "^" : "^(?:.*/)?";
6558
+ const suffix = "(?:/.*)?$";
6559
+ const regex = new RegExp(prefix + body + suffix);
6560
+ return { raw, line, negate, regex };
6561
+ }
6562
+ function _testGitignorePatternsMcp(gitignoreContent, paths) {
6563
+ if (!gitignoreContent.trim()) return { error: "gitignore content is required" };
6564
+ if (paths.length === 0) return { error: "At least one path is required" };
6565
+ const lines = gitignoreContent.split("\n");
6566
+ const patterns = [];
6567
+ lines.forEach((line, idx) => {
6568
+ const compiled = _compileGitignorePatternMcp(line, idx + 1);
6569
+ if (compiled) patterns.push(compiled);
6570
+ });
6571
+ return paths.map((rawPath) => {
6572
+ const path = rawPath.trim().replace(/^\.\//, "");
6573
+ let ignored = false;
6574
+ let matchedPattern = null;
6575
+ let matchedLine = null;
6576
+ for (const p of patterns) {
6577
+ if (p.regex.test(path)) {
6578
+ ignored = !p.negate;
6579
+ matchedPattern = p.raw.trim();
6580
+ matchedLine = p.line;
6581
+ }
6582
+ }
6583
+ return { path, ignored, matchedPattern, matchedLine };
6584
+ });
6585
+ }
6586
+ server.tool(
6587
+ "test_gitignore_patterns",
6588
+ "Test a list of paths against a .gitignore file's contents, following git's matching rules: a leading `!` negates a pattern, a trailing `/` restricts a pattern to directories, and a pattern with no interior `/` can match at any depth. The last matching pattern wins. Does not replicate git's rule that a negation cannot re-include a file whose parent directory was already excluded.",
6589
+ {
6590
+ gitignore: z.string().describe("The .gitignore file contents"),
6591
+ paths: z.array(z.string()).min(1).describe('Relative, forward-slash separated paths to test; use a trailing "/" to mark a directory')
6592
+ },
6593
+ async ({ gitignore, paths }) => {
6594
+ const result = _testGitignorePatternsMcp(gitignore, paths);
6595
+ if (!Array.isArray(result) && "error" in result) return errText(result);
6596
+ return text(JSON.stringify(result, null, 2));
6597
+ }
6598
+ );
6599
+ function _compareUnitPricesMcp(items) {
6600
+ if (!Array.isArray(items) || items.length < 2) {
6601
+ return { error: "At least 2 items are required to compare" };
6602
+ }
6603
+ for (const item of items) {
6604
+ if (!item.label || typeof item.label !== "string") {
6605
+ return { error: "Each item must have a non-empty label" };
6606
+ }
6607
+ if (typeof item.price !== "number" || !Number.isFinite(item.price) || item.price <= 0) {
6608
+ return { error: `Item "${item.label}" must have a price greater than zero` };
6609
+ }
6610
+ if (typeof item.quantity !== "number" || !Number.isFinite(item.quantity) || item.quantity <= 0) {
6611
+ return { error: `Item "${item.label}" must have a quantity greater than zero` };
6612
+ }
6613
+ }
6614
+ const round4 = (n) => Math.round(n * 1e4) / 1e4;
6615
+ const unitPrices = items.map((item) => item.price / item.quantity);
6616
+ const bestUnitPrice = Math.min(...unitPrices);
6617
+ const results = items.map((item, i) => ({
6618
+ label: item.label,
6619
+ price: round4(item.price),
6620
+ quantity: round4(item.quantity),
6621
+ unitPrice: round4(unitPrices[i]),
6622
+ isBest: unitPrices[i] === bestUnitPrice,
6623
+ percentMoreThanBest: round4((unitPrices[i] - bestUnitPrice) / bestUnitPrice * 100)
6624
+ }));
6625
+ const bestLabel = results.find((r) => r.isBest).label;
6626
+ return { items: results, bestLabel };
6627
+ }
6628
+ server.tool(
6629
+ "compare_unit_prices",
6630
+ "Compare two or more package options by price and quantity to find the true cost per unit (price / quantity) for each, flag the cheapest, and report how much more expensive each other option is by percentage. Unit-agnostic: pass quantity in whatever unit makes the options comparable (grams, litres, item count, etc.), no conversion is done.",
6631
+ {
6632
+ items: z.array(z.object({
6633
+ label: z.string().describe("Name for this option"),
6634
+ price: z.number().describe("Total price of this option (must be greater than zero)"),
6635
+ quantity: z.number().describe("Quantity in whatever unit makes options comparable (must be greater than zero)")
6636
+ })).min(2).describe("Two or more package options to compare")
6637
+ },
6638
+ async ({ items }) => {
6639
+ const result = _compareUnitPricesMcp(items);
6640
+ if ("error" in result) return errText(result);
6641
+ return text(JSON.stringify(result, null, 2));
6642
+ }
6643
+ );
6644
+ function _parseCsvRowForSqlMcp(line, delimiter) {
6645
+ const fields = [];
6646
+ let cur = "";
6647
+ let inQuote = false;
6648
+ for (let i = 0; i < line.length; i++) {
6649
+ if (line[i] === '"') {
6650
+ if (inQuote && line[i + 1] === '"') {
6651
+ cur += '"';
6652
+ i++;
6653
+ } else inQuote = !inQuote;
6654
+ } else if (line[i] === delimiter && !inQuote) {
6655
+ fields.push(cur);
6656
+ cur = "";
6657
+ } else {
6658
+ cur += line[i];
6659
+ }
6660
+ }
6661
+ fields.push(cur);
6662
+ return fields;
6663
+ }
6664
+ function _quoteSqlIdentifierMcp(name, dialect) {
6665
+ if (dialect === "mysql") return "`" + name.replace(/`/g, "``") + "`";
6666
+ return '"' + name.replace(/"/g, '""') + '"';
6667
+ }
6668
+ function _formatSqlValueMcp(raw) {
6669
+ const trimmed = raw.trim();
6670
+ if (trimmed === "") return "NULL";
6671
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
6672
+ if (/^(true|false)$/i.test(trimmed)) return trimmed.toLowerCase();
6673
+ return "'" + trimmed.replace(/'/g, "''") + "'";
6674
+ }
6675
+ function _csvToSqlInsertMcp(input, tableName, dialect, batchSize) {
6676
+ if (!input || input.trim() === "") return { error: "Input is empty" };
6677
+ const lines = input.replace(/\r\n/g, "\n").split("\n").filter((l) => l.trim() !== "");
6678
+ if (lines.length === 0) return { error: "No data found" };
6679
+ const allRows = lines.map((l) => _parseCsvRowForSqlMcp(l, ","));
6680
+ const headers = allRows[0].map((h) => h.trim());
6681
+ const rows = allRows.slice(1);
6682
+ const table = (tableName ?? "").trim() || "my_table";
6683
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
6684
+ return { error: "tableName must start with a letter or underscore and contain only letters, digits, and underscores" };
6685
+ }
6686
+ const dial = dialect === "mysql" ? "mysql" : "ansi";
6687
+ const batch = batchSize !== void 0 && Number.isFinite(batchSize) && batchSize > 0 ? Math.floor(batchSize) : 1;
6688
+ const quotedTable = _quoteSqlIdentifierMcp(table, dial);
6689
+ const quotedColumns = headers.map((h) => _quoteSqlIdentifierMcp(h, dial)).join(", ");
6690
+ const statements = [];
6691
+ for (let i = 0; i < rows.length; i += batch) {
6692
+ const chunk = rows.slice(i, i + batch);
6693
+ const tuples = chunk.map((row) => "(" + headers.map((_, idx) => _formatSqlValueMcp(row[idx] ?? "")).join(", ") + ")");
6694
+ statements.push(`INSERT INTO ${quotedTable} (${quotedColumns}) VALUES ${tuples.join(", ")};`);
6695
+ }
6696
+ return {
6697
+ sql: statements.join("\n"),
6698
+ rowCount: rows.length,
6699
+ columnCount: headers.length,
6700
+ columns: headers
6701
+ };
6702
+ }
6703
+ server.tool(
6704
+ "csv_to_sql_insert",
6705
+ "Paste CSV data and get parameterless SQL INSERT statements back, with basic type inference: unquoted for integers/decimals and true/false, NULL for empty cells, single-quoted (with '' escaping) for everything else. Column and table identifiers are quoted per the chosen dialect so reserved words and mixed-case names round-trip safely.",
6706
+ {
6707
+ csv: z.string().describe("CSV data with a header row"),
6708
+ tableName: z.string().optional().describe('Target table name, must be a valid SQL identifier (default "my_table")'),
6709
+ dialect: z.enum(["ansi", "mysql"]).optional().describe('Identifier quoting style: ANSI double quotes or MySQL backticks (default "ansi")'),
6710
+ batchSize: z.number().optional().describe("Rows per INSERT statement (default 1, one statement per row)")
6711
+ },
6712
+ async ({ csv, tableName, dialect, batchSize }) => {
6713
+ const result = _csvToSqlInsertMcp(csv, tableName, dialect, batchSize);
6714
+ if ("error" in result) return errText(result);
6715
+ return text(JSON.stringify(result, null, 2));
6716
+ }
6717
+ );
6424
6718
  var transport = new StdioServerTransport();
6425
6719
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utilix-tech/mcp",
3
- "version": "0.37.0",
3
+ "version": "0.39.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",