@utilix-tech/mcp 0.83.0 → 0.85.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 +8 -0
- package/README.md +7 -3
- package/dist/index.js +406 -0
- package/package.json +1 -1
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.85.0
|
|
6
|
+
- Add `calculate_credit_card_minimum_payment` tool: simulate paying only the minimum payment on a revolving credit card balance, where the minimum is the larger of a percent of the balance or a fixed dollar floor, and report months/years to payoff, total interest, and total paid.
|
|
7
|
+
- Add `check_dash_usage` tool: scan text for likely dash misuse: a hyphen surrounded by spaces, an unconverted double hyphen, and inconsistent spacing around em dashes across the same document.
|
|
8
|
+
|
|
9
|
+
## 0.84.0
|
|
10
|
+
- Add `find_cron_overlaps` tool: walk minute-by-minute over a window of days and flag every minute where two or more cron schedules fire at once, listing which expressions collided.
|
|
11
|
+
- Add `lint_dockerfile` tool: lint a Dockerfile for an unpinned or `:latest` base image, a final stage missing WORKDIR or USER, and ADD used on a plain local file where COPY would do.
|
|
12
|
+
|
|
5
13
|
## 0.83.0
|
|
6
14
|
- Add `extract_markdown_link_references` tool: extract reference-style Markdown links (`[text][ref]`, the `[text][]` shortcut, and `![alt][ref]` image references) and match them against their `[ref]: url` definitions, flagging unresolved references and unused definitions.
|
|
7
15
|
- Add `extract_prompt_template_variables` tool: extract `{{variable}}`-style placeholders from a prompt template, report occurrence counts, flag duplicates, and optionally cross-check against a list of provided variable names.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @utilix-tech/mcp
|
|
2
2
|
|
|
3
|
-
MCP server exposing
|
|
3
|
+
MCP server exposing 264 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
|
|
|
@@ -134,7 +134,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
134
134
|
| `generate_random_data` | Generate random mock data: names, emails, UUIDs, IPs, etc. |
|
|
135
135
|
| `generate_csp_nonce` | Generate a cryptographically random CSP nonce with ready-to-paste header directive and script/style tag snippets |
|
|
136
136
|
|
|
137
|
-
### Text (
|
|
137
|
+
### Text (17 tools)
|
|
138
138
|
| Tool | Description |
|
|
139
139
|
|------|-------------|
|
|
140
140
|
| `convert_case` | Convert text case: camelCase, snake_case, PascalCase, etc. |
|
|
@@ -155,6 +155,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
155
155
|
| `analyze_sentence_lengths` | Split text into sentences and report a histogram of sentence lengths, plus mean, median, standard deviation, and a consistency label |
|
|
156
156
|
| `lookup_emoji` | Search a curated table of ~280 common emoji by name, keyword, or GitHub-style shortcode, or look one up exactly by shortcode, character, or category |
|
|
157
157
|
| `analyze_keyword_density` | Count keyword/phrase frequency and density (occurrences per 100 words) across a block of text, with single-word or 2/3-word phrase modes |
|
|
158
|
+
| `check_dash_usage` | Scan text for likely dash misuse: a hyphen surrounded by spaces, an unconverted double hyphen, and inconsistent spacing around em dashes across the same document |
|
|
158
159
|
|
|
159
160
|
### Time (6 tools)
|
|
160
161
|
| Tool | Description |
|
|
@@ -162,11 +163,12 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
162
163
|
| `unix_timestamp` | Convert a Unix timestamp to human-readable date |
|
|
163
164
|
| `parse_cron` | Parse a cron expression into a readable description |
|
|
164
165
|
| `cron_next_runs` | Show the next N scheduled run times for a cron expression |
|
|
166
|
+
| `find_cron_overlaps` | Find simultaneous fire times across two or more cron schedules |
|
|
165
167
|
| `date_diff` | Calculate the difference between two dates |
|
|
166
168
|
| `convert_timezone` | Convert a date/time between timezones |
|
|
167
169
|
| `convert_iso8601_duration` | Parse an ISO 8601 duration into components and total seconds, or format seconds into a duration string |
|
|
168
170
|
|
|
169
|
-
### Units (
|
|
171
|
+
### Units (47 tools)
|
|
170
172
|
| Tool | Description |
|
|
171
173
|
|------|-------------|
|
|
172
174
|
| `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
|
|
@@ -215,6 +217,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
215
217
|
| `calculate_loan_prepayment_penalty` | Compute the dollar penalty cost of paying a loan off early, given its remaining payoff balance and a prepayment penalty structure: either a flat percent of the payoff balance, or a fixed number of months of interest at the loan's annual rate |
|
|
216
218
|
| `estimate_data_transfer_time` | Estimate the theoretical time to transfer a file of a given size over a connection of a given speed, or (given a target time instead) the bandwidth required to hit it |
|
|
217
219
|
| `calculate_real_rate_of_return` | Apply the Fisher equation to solve for whichever of the nominal rate, inflation rate, or inflation-adjusted real rate is missing, given exactly two of the three |
|
|
220
|
+
| `calculate_credit_card_minimum_payment` | Simulate paying only the minimum payment on a revolving credit card balance, where the minimum is the larger of a percent of the balance or a fixed dollar floor, and report months/years to payoff, total interest, and total paid |
|
|
218
221
|
|
|
219
222
|
### API / Network (51 tools)
|
|
220
223
|
| Tool | Description |
|
|
@@ -292,6 +295,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
292
295
|
| `format_graphql` | Format or minify a GraphQL query or schema |
|
|
293
296
|
| `parse_env` | Parse a .env file and return all key-value pairs |
|
|
294
297
|
| `parse_docker_image` | Parse a Docker image reference into its components |
|
|
298
|
+
| `lint_dockerfile` | Lint a Dockerfile for an unpinned base image, missing WORKDIR/USER, and misused ADD |
|
|
295
299
|
| `lookup_mime` | Look up the MIME type for a file extension, or the extension(s) for a MIME type |
|
|
296
300
|
| `parse_github_url` | Parse a github.com URL (repo, pull request, issue, commit, file, or release) into structured components |
|
|
297
301
|
| `detect_redos` | Statically flag catastrophic-backtracking (ReDoS) risk in a regex: nested quantifiers, overlapping alternation, adjacent overlapping quantifiers |
|
package/dist/index.js
CHANGED
|
@@ -13258,6 +13258,268 @@ function _extractPromptTemplateVariablesMcp(template, providedVars) {
|
|
|
13258
13258
|
}
|
|
13259
13259
|
return { variableCount: order.length, variables, duplicates, missing, unused };
|
|
13260
13260
|
}
|
|
13261
|
+
var _CRON_MONTH_NAMES_MCP = {
|
|
13262
|
+
JAN: 1,
|
|
13263
|
+
FEB: 2,
|
|
13264
|
+
MAR: 3,
|
|
13265
|
+
APR: 4,
|
|
13266
|
+
MAY: 5,
|
|
13267
|
+
JUN: 6,
|
|
13268
|
+
JUL: 7,
|
|
13269
|
+
AUG: 8,
|
|
13270
|
+
SEP: 9,
|
|
13271
|
+
OCT: 10,
|
|
13272
|
+
NOV: 11,
|
|
13273
|
+
DEC: 12
|
|
13274
|
+
};
|
|
13275
|
+
var _CRON_WEEKDAY_NAMES_MCP = {
|
|
13276
|
+
SUN: 0,
|
|
13277
|
+
MON: 1,
|
|
13278
|
+
TUE: 2,
|
|
13279
|
+
WED: 3,
|
|
13280
|
+
THU: 4,
|
|
13281
|
+
FRI: 5,
|
|
13282
|
+
SAT: 6
|
|
13283
|
+
};
|
|
13284
|
+
function _resolveCronNamedValueMcp(token, nameMap) {
|
|
13285
|
+
const upper = token.toUpperCase();
|
|
13286
|
+
if (upper in nameMap) return nameMap[upper];
|
|
13287
|
+
const num = parseInt(token, 10);
|
|
13288
|
+
if (!isNaN(num)) return num;
|
|
13289
|
+
return null;
|
|
13290
|
+
}
|
|
13291
|
+
function _parseCronFieldPartMcp(part, min, max, nameMap) {
|
|
13292
|
+
if (part.startsWith("*/")) {
|
|
13293
|
+
const step = parseInt(part.slice(2), 10);
|
|
13294
|
+
if (isNaN(step) || step < 1) return { error: `Invalid step in "${part}"` };
|
|
13295
|
+
const vals = [];
|
|
13296
|
+
for (let i = min; i <= max; i += step) vals.push(i);
|
|
13297
|
+
return vals;
|
|
13298
|
+
}
|
|
13299
|
+
if (part.includes("-")) {
|
|
13300
|
+
const stepSplit = part.split("/");
|
|
13301
|
+
const rangePart = stepSplit[0];
|
|
13302
|
+
const step = stepSplit.length > 1 ? parseInt(stepSplit[1], 10) : 1;
|
|
13303
|
+
if (isNaN(step) || step < 1) return { error: `Invalid step in "${part}"` };
|
|
13304
|
+
const [startStr, endStr] = rangePart.split("-");
|
|
13305
|
+
const start = _resolveCronNamedValueMcp(startStr, nameMap);
|
|
13306
|
+
const end = _resolveCronNamedValueMcp(endStr, nameMap);
|
|
13307
|
+
if (start === null || end === null) return { error: `Invalid range in "${part}"` };
|
|
13308
|
+
if (start < min || end > max || start > end) return { error: `Range out of bounds in "${part}"` };
|
|
13309
|
+
const vals = [];
|
|
13310
|
+
for (let i = start; i <= end; i += step) vals.push(i);
|
|
13311
|
+
return vals;
|
|
13312
|
+
}
|
|
13313
|
+
if (part.includes("/")) {
|
|
13314
|
+
const [startStr, stepStr] = part.split("/");
|
|
13315
|
+
const start = _resolveCronNamedValueMcp(startStr, nameMap);
|
|
13316
|
+
const step = parseInt(stepStr, 10);
|
|
13317
|
+
if (start === null || isNaN(step) || step < 1) return { error: `Invalid expression "${part}"` };
|
|
13318
|
+
if (start < min || start > max) return { error: `Value out of bounds in "${part}"` };
|
|
13319
|
+
const vals = [];
|
|
13320
|
+
for (let i = start; i <= max; i += step) vals.push(i);
|
|
13321
|
+
return vals;
|
|
13322
|
+
}
|
|
13323
|
+
const val = _resolveCronNamedValueMcp(part, nameMap);
|
|
13324
|
+
if (val === null) return { error: `Invalid value "${part}"` };
|
|
13325
|
+
if (val < min || val > max) return { error: `Value ${val} out of bounds [${min}-${max}]` };
|
|
13326
|
+
return [val];
|
|
13327
|
+
}
|
|
13328
|
+
function _parseCronFieldMcp(raw, min, max, nameMap = {}) {
|
|
13329
|
+
if (raw === "*") {
|
|
13330
|
+
const values2 = [];
|
|
13331
|
+
for (let i = min; i <= max; i++) values2.push(i);
|
|
13332
|
+
return { raw, values: values2, min, max };
|
|
13333
|
+
}
|
|
13334
|
+
const parts = raw.split(",");
|
|
13335
|
+
const allValues = /* @__PURE__ */ new Set();
|
|
13336
|
+
for (const part of parts) {
|
|
13337
|
+
const result = _parseCronFieldPartMcp(part.trim(), min, max, nameMap);
|
|
13338
|
+
if (typeof result === "object" && "error" in result) return result;
|
|
13339
|
+
for (const v of result) allValues.add(v);
|
|
13340
|
+
}
|
|
13341
|
+
const values = Array.from(allValues).sort((a, b) => a - b);
|
|
13342
|
+
return { raw, values, min, max };
|
|
13343
|
+
}
|
|
13344
|
+
function _parseCronExpressionMcp(expr) {
|
|
13345
|
+
const trimmed = expr.trim();
|
|
13346
|
+
const fields = trimmed.split(/\s+/);
|
|
13347
|
+
if (fields.length < 5) return { error: "Cron expression must have exactly 5 fields" };
|
|
13348
|
+
if (fields.length > 5) return { error: "Cron expression must have exactly 5 fields (6+ field expressions not supported)" };
|
|
13349
|
+
const [minRaw, hourRaw, domRaw, monRaw, dowRaw] = fields;
|
|
13350
|
+
const minute = _parseCronFieldMcp(minRaw, 0, 59, {});
|
|
13351
|
+
if (typeof minute === "object" && "error" in minute) return { error: `Minute field: ${minute.error}` };
|
|
13352
|
+
const hour = _parseCronFieldMcp(hourRaw, 0, 23, {});
|
|
13353
|
+
if (typeof hour === "object" && "error" in hour) return { error: `Hour field: ${hour.error}` };
|
|
13354
|
+
const dayOfMonth = _parseCronFieldMcp(domRaw, 1, 31, {});
|
|
13355
|
+
if (typeof dayOfMonth === "object" && "error" in dayOfMonth) return { error: `Day-of-month field: ${dayOfMonth.error}` };
|
|
13356
|
+
const month = _parseCronFieldMcp(monRaw, 1, 12, _CRON_MONTH_NAMES_MCP);
|
|
13357
|
+
if (typeof month === "object" && "error" in month) return { error: `Month field: ${month.error}` };
|
|
13358
|
+
const dayOfWeek = _parseCronFieldMcp(dowRaw, 0, 6, _CRON_WEEKDAY_NAMES_MCP);
|
|
13359
|
+
if (typeof dayOfWeek === "object" && "error" in dayOfWeek) return { error: `Day-of-week field: ${dayOfWeek.error}` };
|
|
13360
|
+
return {
|
|
13361
|
+
minute,
|
|
13362
|
+
hour,
|
|
13363
|
+
dayOfMonth,
|
|
13364
|
+
month,
|
|
13365
|
+
dayOfWeek
|
|
13366
|
+
};
|
|
13367
|
+
}
|
|
13368
|
+
function _matchesCronMcp(date, parsed) {
|
|
13369
|
+
const min = date.getMinutes();
|
|
13370
|
+
const hour = date.getHours();
|
|
13371
|
+
const dom = date.getDate();
|
|
13372
|
+
const month = date.getMonth() + 1;
|
|
13373
|
+
const dow = date.getDay();
|
|
13374
|
+
if (!parsed.minute.values.includes(min)) return false;
|
|
13375
|
+
if (!parsed.hour.values.includes(hour)) return false;
|
|
13376
|
+
if (!parsed.month.values.includes(month)) return false;
|
|
13377
|
+
const domWild = parsed.dayOfMonth.raw === "*";
|
|
13378
|
+
const dowWild = parsed.dayOfWeek.raw === "*";
|
|
13379
|
+
if (domWild && dowWild) return true;
|
|
13380
|
+
if (domWild) return parsed.dayOfWeek.values.includes(dow);
|
|
13381
|
+
if (dowWild) return parsed.dayOfMonth.values.includes(dom);
|
|
13382
|
+
return parsed.dayOfMonth.values.includes(dom) || parsed.dayOfWeek.values.includes(dow);
|
|
13383
|
+
}
|
|
13384
|
+
var _MAX_CRON_OVERLAPS_MCP = 200;
|
|
13385
|
+
function _findCronOverlapsMcp(expressions, windowDays) {
|
|
13386
|
+
if (expressions.length < 2) return { error: "Provide at least two cron expressions to check for overlaps" };
|
|
13387
|
+
if (expressions.length > 10) return { error: "Provide 10 or fewer cron expressions" };
|
|
13388
|
+
if (windowDays < 1 || windowDays > 365) return { error: "windowDays must be between 1 and 365" };
|
|
13389
|
+
const parsedList = [];
|
|
13390
|
+
for (let i = 0; i < expressions.length; i++) {
|
|
13391
|
+
const parsed = _parseCronExpressionMcp(expressions[i]);
|
|
13392
|
+
if (typeof parsed === "object" && "error" in parsed) {
|
|
13393
|
+
return { error: `Expression ${i + 1} ("${expressions[i]}"): ${parsed.error}` };
|
|
13394
|
+
}
|
|
13395
|
+
parsedList.push(parsed);
|
|
13396
|
+
}
|
|
13397
|
+
const cursor = /* @__PURE__ */ new Date();
|
|
13398
|
+
cursor.setSeconds(0, 0);
|
|
13399
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
13400
|
+
const endTime = cursor.getTime() + windowDays * 24 * 60 * 60 * 1e3;
|
|
13401
|
+
const overlaps = [];
|
|
13402
|
+
let truncated = false;
|
|
13403
|
+
while (cursor.getTime() < endTime) {
|
|
13404
|
+
const matched = [];
|
|
13405
|
+
for (let i = 0; i < parsedList.length; i++) {
|
|
13406
|
+
if (_matchesCronMcp(cursor, parsedList[i])) matched.push(i);
|
|
13407
|
+
}
|
|
13408
|
+
if (matched.length >= 2) {
|
|
13409
|
+
if (overlaps.length >= _MAX_CRON_OVERLAPS_MCP) {
|
|
13410
|
+
truncated = true;
|
|
13411
|
+
break;
|
|
13412
|
+
}
|
|
13413
|
+
overlaps.push({ date: new Date(cursor).toISOString(), expressionIndexes: matched });
|
|
13414
|
+
}
|
|
13415
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
13416
|
+
}
|
|
13417
|
+
return { overlaps, windowDays, truncated };
|
|
13418
|
+
}
|
|
13419
|
+
function _parseDockerfileInstructionsMcp(content) {
|
|
13420
|
+
const rawLines = content.replace(/\r\n/g, "\n").split("\n");
|
|
13421
|
+
const instructions = [];
|
|
13422
|
+
let buffer = "";
|
|
13423
|
+
let bufferStartLine = 0;
|
|
13424
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
13425
|
+
const lineNo = i + 1;
|
|
13426
|
+
const line = rawLines[i];
|
|
13427
|
+
const trimmed = line.trim();
|
|
13428
|
+
if (buffer === "") {
|
|
13429
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
13430
|
+
bufferStartLine = lineNo;
|
|
13431
|
+
buffer = line.trimEnd();
|
|
13432
|
+
} else {
|
|
13433
|
+
buffer += "\n" + line.trimEnd();
|
|
13434
|
+
}
|
|
13435
|
+
if (buffer.endsWith("\\")) {
|
|
13436
|
+
buffer = buffer.slice(0, -1);
|
|
13437
|
+
continue;
|
|
13438
|
+
}
|
|
13439
|
+
const match = buffer.trim().match(/^([A-Za-z]+)\s*([\s\S]*)$/);
|
|
13440
|
+
if (match) {
|
|
13441
|
+
instructions.push({ instruction: match[1].toUpperCase(), args: match[2].trim(), line: bufferStartLine });
|
|
13442
|
+
}
|
|
13443
|
+
buffer = "";
|
|
13444
|
+
}
|
|
13445
|
+
return instructions;
|
|
13446
|
+
}
|
|
13447
|
+
var _DOCKERFILE_TAR_RE_MCP = /\.(tar(\.(gz|bz2|xz))?|tgz|tbz2|txz)$/i;
|
|
13448
|
+
function _lintDockerfileMcp(content) {
|
|
13449
|
+
if (!content || !content.trim()) return { error: "Dockerfile content is required" };
|
|
13450
|
+
const instructions = _parseDockerfileInstructionsMcp(content);
|
|
13451
|
+
if (instructions.length === 0) return { error: "No Dockerfile instructions found" };
|
|
13452
|
+
const fromInstructions = instructions.filter((i) => i.instruction === "FROM");
|
|
13453
|
+
if (fromInstructions.length === 0) return { error: "No FROM instruction found; this does not look like a valid Dockerfile" };
|
|
13454
|
+
const stageNames = /* @__PURE__ */ new Set();
|
|
13455
|
+
const stages = fromInstructions.map((inst, idx) => {
|
|
13456
|
+
const asMatch = inst.args.match(/^(\S+)(?:\s+AS\s+(\S+))?/i);
|
|
13457
|
+
const baseImage = asMatch ? asMatch[1] : inst.args.split(/\s+/)[0];
|
|
13458
|
+
const name = asMatch && asMatch[2] ? asMatch[2] : null;
|
|
13459
|
+
if (name) stageNames.add(name.toLowerCase());
|
|
13460
|
+
return { index: idx, name, baseImage, fromLine: inst.line };
|
|
13461
|
+
});
|
|
13462
|
+
const findings = [];
|
|
13463
|
+
for (const stage of stages) {
|
|
13464
|
+
const base = stage.baseImage;
|
|
13465
|
+
if (base.toLowerCase() === "scratch") continue;
|
|
13466
|
+
if (stageNames.has(base.toLowerCase())) continue;
|
|
13467
|
+
const withoutDigest = base.split("@")[0];
|
|
13468
|
+
const hasTag = withoutDigest.includes(":");
|
|
13469
|
+
const tag = hasTag ? withoutDigest.split(":").pop() : null;
|
|
13470
|
+
if (!hasTag) {
|
|
13471
|
+
findings.push({ rule: "unpinned-base-image", severity: "warning", line: stage.fromLine, message: `FROM ${base} has no tag, so it resolves to ":latest", which can silently change what gets built on a future run. Pin an explicit version tag instead.` });
|
|
13472
|
+
} else if (tag === "latest") {
|
|
13473
|
+
findings.push({ rule: "unpinned-base-image", severity: "warning", line: stage.fromLine, message: `FROM ${base} is explicitly pinned to the "latest" tag, which is a moving target rather than a fixed version. Pin an explicit version tag instead.` });
|
|
13474
|
+
}
|
|
13475
|
+
}
|
|
13476
|
+
const finalStage = stages[stages.length - 1];
|
|
13477
|
+
const finalStageInstructions = instructions.filter((i) => i.line >= finalStage.fromLine);
|
|
13478
|
+
if (!finalStageInstructions.some((i) => i.instruction === "WORKDIR")) {
|
|
13479
|
+
findings.push({ rule: "missing-workdir", severity: "warning", line: finalStage.fromLine, message: 'No WORKDIR instruction in the final stage, so RUN/CMD/ENTRYPOINT execute from "/" by default. Set an explicit WORKDIR.' });
|
|
13480
|
+
}
|
|
13481
|
+
if (!finalStageInstructions.some((i) => i.instruction === "USER")) {
|
|
13482
|
+
findings.push({ rule: "running-as-root", severity: "warning", line: finalStage.fromLine, message: "No USER instruction in the final stage, so the container runs as root by default. Add a non-root USER unless root is genuinely required." });
|
|
13483
|
+
}
|
|
13484
|
+
for (const inst of instructions) {
|
|
13485
|
+
if (inst.instruction !== "ADD") continue;
|
|
13486
|
+
const parts = inst.args.split(/\s+/).filter(Boolean).filter((p) => !p.startsWith("--"));
|
|
13487
|
+
if (parts.length < 2) continue;
|
|
13488
|
+
for (const src of parts.slice(0, -1)) {
|
|
13489
|
+
const isUrl = /^https?:\/\//i.test(src);
|
|
13490
|
+
const isTar = _DOCKERFILE_TAR_RE_MCP.test(src);
|
|
13491
|
+
if (!isUrl && !isTar) {
|
|
13492
|
+
findings.push({ rule: "add-vs-copy", severity: "info", line: inst.line, message: `ADD ${src} copies a local file/directory, not a URL or an auto-extracting archive. Use COPY instead, since ADD's extra behavior (remote fetch, tar auto-extract) doesn't apply here.` });
|
|
13493
|
+
}
|
|
13494
|
+
}
|
|
13495
|
+
}
|
|
13496
|
+
return { findings, stages };
|
|
13497
|
+
}
|
|
13498
|
+
server.tool(
|
|
13499
|
+
"lint_dockerfile",
|
|
13500
|
+
"Lint a Dockerfile for 4 common mistakes: a base image with no tag or pinned to `:latest`, a final stage with no WORKDIR, a final stage with no USER (running as root), and ADD used on a plain local file/directory where COPY would do. Returns a list of findings plus the parsed build stages.",
|
|
13501
|
+
{
|
|
13502
|
+
content: z.string().min(1).describe("The full Dockerfile text")
|
|
13503
|
+
},
|
|
13504
|
+
async ({ content }) => {
|
|
13505
|
+
const result = _lintDockerfileMcp(content);
|
|
13506
|
+
if ("error" in result) return errText(result);
|
|
13507
|
+
return text(JSON.stringify(result, null, 2));
|
|
13508
|
+
}
|
|
13509
|
+
);
|
|
13510
|
+
server.tool(
|
|
13511
|
+
"find_cron_overlaps",
|
|
13512
|
+
"Given two or more standard 5-field cron expressions, find every minute over the next N days where two or more of them fire at the same time, for example a nightly backup job colliding with a report job. Returns each colliding minute with the zero-based indexes of every expression that matched.",
|
|
13513
|
+
{
|
|
13514
|
+
expressions: z.array(z.string()).min(2).max(10).describe("Two to ten standard 5-field cron expressions to check against each other"),
|
|
13515
|
+
windowDays: z.number().min(1).max(365).optional().describe("How many days ahead to check, default 30")
|
|
13516
|
+
},
|
|
13517
|
+
async ({ expressions, windowDays }) => {
|
|
13518
|
+
const result = _findCronOverlapsMcp(expressions, windowDays ?? 30);
|
|
13519
|
+
if ("error" in result) return errText(result);
|
|
13520
|
+
return text(JSON.stringify(result, null, 2));
|
|
13521
|
+
}
|
|
13522
|
+
);
|
|
13261
13523
|
server.tool(
|
|
13262
13524
|
"extract_prompt_template_variables",
|
|
13263
13525
|
"Extract every {{variable}}-style placeholder from a prompt template, reporting each unique variable's occurrence count and flagging ones used more than once. Optionally cross-check against a providedVars list to flag variables the template uses that are missing, and provided names the template never references.",
|
|
@@ -13271,5 +13533,149 @@ server.tool(
|
|
|
13271
13533
|
return text(JSON.stringify(result, null, 2));
|
|
13272
13534
|
}
|
|
13273
13535
|
);
|
|
13536
|
+
var _CC_MIN_PAYMENT_MAX_MONTHS_MCP = 1200;
|
|
13537
|
+
function _calculateCreditCardMinimumPaymentMcp(balance, aprPercent, minPaymentPercent, minPaymentFloor) {
|
|
13538
|
+
if (!Number.isFinite(balance) || balance <= 0) {
|
|
13539
|
+
return { error: "balance must be a finite number greater than zero" };
|
|
13540
|
+
}
|
|
13541
|
+
if (!Number.isFinite(aprPercent) || aprPercent < 0) {
|
|
13542
|
+
return { error: "aprPercent must be a finite, non-negative number" };
|
|
13543
|
+
}
|
|
13544
|
+
if (!Number.isFinite(minPaymentPercent) || minPaymentPercent <= 0) {
|
|
13545
|
+
return { error: "minPaymentPercent must be a finite number greater than zero" };
|
|
13546
|
+
}
|
|
13547
|
+
if (!Number.isFinite(minPaymentFloor) || minPaymentFloor < 0) {
|
|
13548
|
+
return { error: "minPaymentFloor must be a finite, non-negative number" };
|
|
13549
|
+
}
|
|
13550
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
13551
|
+
const monthlyRate = aprPercent / 100 / 12;
|
|
13552
|
+
let bal = balance;
|
|
13553
|
+
let totalInterestPaid = 0;
|
|
13554
|
+
let totalPaid = 0;
|
|
13555
|
+
let firstMonthPayment = 0;
|
|
13556
|
+
let finalMonthPayment = 0;
|
|
13557
|
+
let month = 0;
|
|
13558
|
+
while (bal > 5e-3 && month < _CC_MIN_PAYMENT_MAX_MONTHS_MCP) {
|
|
13559
|
+
month++;
|
|
13560
|
+
const interest = bal * monthlyRate;
|
|
13561
|
+
bal += interest;
|
|
13562
|
+
totalInterestPaid += interest;
|
|
13563
|
+
let payment = Math.max(bal * (minPaymentPercent / 100), minPaymentFloor);
|
|
13564
|
+
if (payment > bal) payment = bal;
|
|
13565
|
+
bal -= payment;
|
|
13566
|
+
totalPaid += payment;
|
|
13567
|
+
finalMonthPayment = round2(payment);
|
|
13568
|
+
if (month === 1) firstMonthPayment = round2(payment);
|
|
13569
|
+
}
|
|
13570
|
+
if (bal > 5e-3) {
|
|
13571
|
+
return { error: `balance cannot be paid off within ${_CC_MIN_PAYMENT_MAX_MONTHS_MCP} months at this minimum-payment formula; increase minPaymentPercent or minPaymentFloor` };
|
|
13572
|
+
}
|
|
13573
|
+
return {
|
|
13574
|
+
monthsToPayoff: month,
|
|
13575
|
+
yearsToPayoff: round2(month / 12),
|
|
13576
|
+
totalInterestPaid: round2(totalInterestPaid),
|
|
13577
|
+
totalPaid: round2(totalPaid),
|
|
13578
|
+
firstMonthPayment,
|
|
13579
|
+
finalMonthPayment
|
|
13580
|
+
};
|
|
13581
|
+
}
|
|
13582
|
+
server.tool(
|
|
13583
|
+
"calculate_credit_card_minimum_payment",
|
|
13584
|
+
`Simulate paying only the minimum payment on a revolving credit card balance every month, where the minimum payment is whichever is larger of a percent of the current balance or a fixed dollar floor. Reports months and years to payoff, total interest, total paid, and the first vs. final month's payment amount, so the "minimum payment trap" becomes visible.`,
|
|
13585
|
+
{
|
|
13586
|
+
balance: z.number().positive().describe("Current revolving balance owed"),
|
|
13587
|
+
aprPercent: z.number().min(0).describe("Annual percentage rate, as a percent"),
|
|
13588
|
+
minPaymentPercent: z.number().positive().describe("Minimum payment as a percent of the current balance, as a percent"),
|
|
13589
|
+
minPaymentFloor: z.number().min(0).optional().describe("Fixed dollar floor for the minimum payment, default 0")
|
|
13590
|
+
},
|
|
13591
|
+
async ({ balance, aprPercent, minPaymentPercent, minPaymentFloor }) => {
|
|
13592
|
+
const result = _calculateCreditCardMinimumPaymentMcp(balance, aprPercent, minPaymentPercent, minPaymentFloor ?? 0);
|
|
13593
|
+
if ("error" in result) return errText(result);
|
|
13594
|
+
return text(JSON.stringify(result, null, 2));
|
|
13595
|
+
}
|
|
13596
|
+
);
|
|
13597
|
+
function _dashContextSnippetMcp(input, index, matchLen) {
|
|
13598
|
+
const start = Math.max(0, index - 20);
|
|
13599
|
+
const end = Math.min(input.length, index + matchLen + 20);
|
|
13600
|
+
const body = input.slice(start, end).replace(/\s+/g, " ").trim();
|
|
13601
|
+
return (start > 0 ? "\u2026" : "") + body + (end < input.length ? "\u2026" : "");
|
|
13602
|
+
}
|
|
13603
|
+
function _checkDashUsageMcp(input) {
|
|
13604
|
+
const issues = [];
|
|
13605
|
+
const enDashCount = (input.match(/–/g) ?? []).length;
|
|
13606
|
+
const hyphenCount = (input.match(/-/g) ?? []).length;
|
|
13607
|
+
const doubleHyphenMatches = [...input.matchAll(/-{2,}/g)];
|
|
13608
|
+
for (const m of doubleHyphenMatches) {
|
|
13609
|
+
const index = m.index ?? 0;
|
|
13610
|
+
issues.push({
|
|
13611
|
+
type: "double-hyphen",
|
|
13612
|
+
index,
|
|
13613
|
+
context: _dashContextSnippetMcp(input, index, m[0].length),
|
|
13614
|
+
message: `Found ${m[0].length} consecutive hyphens ("${m[0]}"); likely meant to be an em dash (\u2014) or en dash (\u2013).`
|
|
13615
|
+
});
|
|
13616
|
+
}
|
|
13617
|
+
const spacedHyphenMatches = [...input.matchAll(/(?<=\S) - (?=\S)/g)];
|
|
13618
|
+
for (const m of spacedHyphenMatches) {
|
|
13619
|
+
const index = m.index ?? 0;
|
|
13620
|
+
issues.push({
|
|
13621
|
+
type: "spaced-hyphen",
|
|
13622
|
+
index,
|
|
13623
|
+
context: _dashContextSnippetMcp(input, index, m[0].length),
|
|
13624
|
+
message: "A hyphen surrounded by spaces usually signals an intended em dash (\u2014), not a hyphenated compound word."
|
|
13625
|
+
});
|
|
13626
|
+
}
|
|
13627
|
+
const emDashOccurrences = [...input.matchAll(/—/g)];
|
|
13628
|
+
let spacedEm = 0;
|
|
13629
|
+
let unspacedEm = 0;
|
|
13630
|
+
for (const m of emDashOccurrences) {
|
|
13631
|
+
const idx = m.index ?? 0;
|
|
13632
|
+
const before = input[idx - 1];
|
|
13633
|
+
const after = input[idx + 1];
|
|
13634
|
+
if (before === " " && after === " ") spacedEm++;
|
|
13635
|
+
else if (before !== " " && after !== " ") unspacedEm++;
|
|
13636
|
+
}
|
|
13637
|
+
let dominantEmDashStyle = "none";
|
|
13638
|
+
let emDashSpacingConsistent = true;
|
|
13639
|
+
if (emDashOccurrences.length > 0) {
|
|
13640
|
+
dominantEmDashStyle = spacedEm >= unspacedEm ? "spaced" : "unspaced";
|
|
13641
|
+
emDashSpacingConsistent = spacedEm === 0 || unspacedEm === 0;
|
|
13642
|
+
if (!emDashSpacingConsistent) {
|
|
13643
|
+
for (const m of emDashOccurrences) {
|
|
13644
|
+
const idx = m.index ?? 0;
|
|
13645
|
+
const before = input[idx - 1];
|
|
13646
|
+
const after = input[idx + 1];
|
|
13647
|
+
const isSpaced = before === " " && after === " ";
|
|
13648
|
+
const matchesDominant = dominantEmDashStyle === "spaced" ? isSpaced : !isSpaced;
|
|
13649
|
+
if (!matchesDominant) {
|
|
13650
|
+
issues.push({
|
|
13651
|
+
type: "inconsistent-em-dash-spacing",
|
|
13652
|
+
index: idx,
|
|
13653
|
+
context: _dashContextSnippetMcp(input, idx, 1),
|
|
13654
|
+
message: `This em dash is ${isSpaced ? "spaced" : "unspaced"}, but the document mostly uses ${dominantEmDashStyle} em dashes.`
|
|
13655
|
+
});
|
|
13656
|
+
}
|
|
13657
|
+
}
|
|
13658
|
+
}
|
|
13659
|
+
}
|
|
13660
|
+
issues.sort((a, b) => a.index - b.index);
|
|
13661
|
+
return {
|
|
13662
|
+
emDashCount: emDashOccurrences.length,
|
|
13663
|
+
enDashCount,
|
|
13664
|
+
hyphenCount,
|
|
13665
|
+
doubleHyphenCount: doubleHyphenMatches.length,
|
|
13666
|
+
spacedHyphenCount: spacedHyphenMatches.length,
|
|
13667
|
+
emDashSpacingConsistent,
|
|
13668
|
+
dominantEmDashStyle,
|
|
13669
|
+
issues
|
|
13670
|
+
};
|
|
13671
|
+
}
|
|
13672
|
+
server.tool(
|
|
13673
|
+
"check_dash_usage",
|
|
13674
|
+
'Scan text for likely dash misuse: a hyphen surrounded by spaces where an em dash is probably intended ("word - word"), a double hyphen ("--") left unconverted, and inconsistent spacing around em dashes across the same document (mixing "word \u2014 word" and "word\u2014word"). Pure regex heuristic, not a grammar parser.',
|
|
13675
|
+
{ text: z.string().describe("The text to scan for dash usage issues") },
|
|
13676
|
+
async ({ text: input }) => {
|
|
13677
|
+
return text(JSON.stringify(_checkDashUsageMcp(input), null, 2));
|
|
13678
|
+
}
|
|
13679
|
+
);
|
|
13274
13680
|
var transport = new StdioServerTransport();
|
|
13275
13681
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@utilix-tech/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.85.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",
|