@utilix-tech/mcp 0.82.0 → 0.84.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 +374 -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.84.0
|
|
6
|
+
- 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.
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
## 0.83.0
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
5
13
|
## 0.82.0
|
|
6
14
|
- Add `generate_robots_txt` tool: build a robots.txt file from User-agent groups with Allow/Disallow rules and an optional Crawl-delay, plus a list of Sitemap URLs. The generator-side sibling of `validate_robots_txt`.
|
|
7
15
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @utilix-tech/mcp
|
|
2
2
|
|
|
3
|
-
MCP server exposing
|
|
3
|
+
MCP server exposing 262 developer utility tools to Claude, Cursor, VS Code Copilot, and any [Model Context Protocol](https://modelcontextprotocol.io) compatible AI assistant.
|
|
4
4
|
|
|
5
5
|
No API key required. All tools run locally. Requires Node.js 18 or later.
|
|
6
6
|
|
|
@@ -162,6 +162,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
162
162
|
| `unix_timestamp` | Convert a Unix timestamp to human-readable date |
|
|
163
163
|
| `parse_cron` | Parse a cron expression into a readable description |
|
|
164
164
|
| `cron_next_runs` | Show the next N scheduled run times for a cron expression |
|
|
165
|
+
| `find_cron_overlaps` | Find simultaneous fire times across two or more cron schedules |
|
|
165
166
|
| `date_diff` | Calculate the difference between two dates |
|
|
166
167
|
| `convert_timezone` | Convert a date/time between timezones |
|
|
167
168
|
| `convert_iso8601_duration` | Parse an ISO 8601 duration into components and total seconds, or format seconds into a duration string |
|
|
@@ -282,7 +283,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
282
283
|
| `color_palette` | Generate a color palette from a base color |
|
|
283
284
|
| `color_shades` | Generate shades or tints from a base color |
|
|
284
285
|
|
|
285
|
-
### Code (
|
|
286
|
+
### Code (25 tools)
|
|
286
287
|
| Tool | Description |
|
|
287
288
|
|------|-------------|
|
|
288
289
|
| `format_sql` | Format a SQL query with proper indentation |
|
|
@@ -292,6 +293,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
292
293
|
| `format_graphql` | Format or minify a GraphQL query or schema |
|
|
293
294
|
| `parse_env` | Parse a .env file and return all key-value pairs |
|
|
294
295
|
| `parse_docker_image` | Parse a Docker image reference into its components |
|
|
296
|
+
| `lint_dockerfile` | Lint a Dockerfile for an unpinned base image, missing WORKDIR/USER, and misused ADD |
|
|
295
297
|
| `lookup_mime` | Look up the MIME type for a file extension, or the extension(s) for a MIME type |
|
|
296
298
|
| `parse_github_url` | Parse a github.com URL (repo, pull request, issue, commit, file, or release) into structured components |
|
|
297
299
|
| `detect_redos` | Statically flag catastrophic-backtracking (ReDoS) risk in a regex: nested quantifiers, overlapping alternation, adjacent overlapping quantifiers |
|
|
@@ -309,6 +311,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
309
311
|
| `parse_connection_string` | Parse a database connection URI (postgres, mysql, mongodb, redis, amqp, and similar) into its scheme, credentials, hosts, database, and query parameters |
|
|
310
312
|
| `validate_fence_language_tags` | Scan a Markdown document's fenced code blocks and flag a missing, unknown, or inconsistently-cased language tag against a curated list of common syntax-highlighter identifiers |
|
|
311
313
|
| `count_sql_placeholders` | Count positional (`?`, `$1`) and named (`:name`, `@name`) parameter placeholders in a SQL query, flagging mixed styles and numbered-sequence gaps or duplicates |
|
|
314
|
+
| `extract_markdown_link_references` | Extract every reference-style Markdown link ([text][ref], [text][], ![alt][ref]) and match it against its [ref]: url definition, flagging unresolved references and unused definitions |
|
|
312
315
|
|
|
313
316
|
### Data (16 tools)
|
|
314
317
|
| Tool | Description |
|
|
@@ -344,7 +347,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
344
347
|
| `unicode_inspect` | Inspect Unicode code points of a string |
|
|
345
348
|
| `optimize_svg` | Optimize and minify an SVG string |
|
|
346
349
|
|
|
347
|
-
### AI / Agent (
|
|
350
|
+
### AI / Agent (30 tools)
|
|
348
351
|
| Tool | Description |
|
|
349
352
|
|------|-------------|
|
|
350
353
|
| `estimate_tokens` | Estimate token count and cost for an LLM prompt |
|
|
@@ -376,6 +379,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
376
379
|
| `format_few_shot_examples` | Format input/output example pairs into a numbered, XML-tagged, or delimiter-separated few-shot prompt template |
|
|
377
380
|
| `lint_tool_schema` | Lint an OpenAI, Anthropic, or MCP tool/function-calling definition for missing descriptions, invalid names, empty enums, and required/properties mismatches |
|
|
378
381
|
| `calculate_retry_backoff_schedule` | Compute the full sequence of delays an exponential-backoff retry policy would actually produce, given a base delay, multiplier, and max retry count, optionally capped at a max delay and with full/equal jitter ranges |
|
|
382
|
+
| `extract_prompt_template_variables` | Extract every {{variable}}-style placeholder from a prompt template, report occurrence counts, flag duplicates, and optionally cross-check against a list of provided variable names |
|
|
379
383
|
|
|
380
384
|
### Image (4 tools)
|
|
381
385
|
| Tool | Description |
|
package/dist/index.js
CHANGED
|
@@ -13159,5 +13159,379 @@ server.tool(
|
|
|
13159
13159
|
return text(JSON.stringify(result, null, 2));
|
|
13160
13160
|
}
|
|
13161
13161
|
);
|
|
13162
|
+
var _LINK_REF_DEFINITION_REGEX_MCP = /^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\)))?\s*$/;
|
|
13163
|
+
var _LINK_REF_USAGE_REGEX_MCP = /(!?)\[([^[\]\n]*)\]\[([^[\]\n]*)\]/g;
|
|
13164
|
+
function _normalizeLinkLabelMcp(label) {
|
|
13165
|
+
return label.trim().replace(/\s+/g, " ").toLowerCase();
|
|
13166
|
+
}
|
|
13167
|
+
function _extractMarkdownLinkReferencesMcp(markdown) {
|
|
13168
|
+
const trimmed = markdown.trim();
|
|
13169
|
+
if (!trimmed) return { error: "Empty input" };
|
|
13170
|
+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
13171
|
+
const definitions = [];
|
|
13172
|
+
const definitionsByLabel = /* @__PURE__ */ new Map();
|
|
13173
|
+
for (const line of lines) {
|
|
13174
|
+
const match = line.match(_LINK_REF_DEFINITION_REGEX_MCP);
|
|
13175
|
+
if (!match) continue;
|
|
13176
|
+
const label = match[1];
|
|
13177
|
+
const normalized = _normalizeLinkLabelMcp(label);
|
|
13178
|
+
if (definitionsByLabel.has(normalized)) continue;
|
|
13179
|
+
const def = { label, url: match[2], title: match[3] ?? match[4] ?? match[5] ?? void 0 };
|
|
13180
|
+
definitionsByLabel.set(normalized, def);
|
|
13181
|
+
definitions.push(def);
|
|
13182
|
+
}
|
|
13183
|
+
const usages = [];
|
|
13184
|
+
for (const m of markdown.matchAll(_LINK_REF_USAGE_REGEX_MCP)) {
|
|
13185
|
+
const image = m[1] === "!";
|
|
13186
|
+
const text2 = m[2];
|
|
13187
|
+
const rawRef = m[3];
|
|
13188
|
+
const label = rawRef.trim() === "" ? text2 : rawRef;
|
|
13189
|
+
usages.push({ text: text2, label, image });
|
|
13190
|
+
}
|
|
13191
|
+
if (definitions.length === 0 && usages.length === 0) {
|
|
13192
|
+
return { error: "No reference-style links or definitions found" };
|
|
13193
|
+
}
|
|
13194
|
+
const usedLabels = /* @__PURE__ */ new Set();
|
|
13195
|
+
const resolved = [];
|
|
13196
|
+
const unresolvedReferences = [];
|
|
13197
|
+
for (const usage of usages) {
|
|
13198
|
+
const normalized = _normalizeLinkLabelMcp(usage.label);
|
|
13199
|
+
const def = definitionsByLabel.get(normalized);
|
|
13200
|
+
if (def) {
|
|
13201
|
+
usedLabels.add(normalized);
|
|
13202
|
+
resolved.push({ ...usage, url: def.url, title: def.title });
|
|
13203
|
+
} else {
|
|
13204
|
+
unresolvedReferences.push(usage);
|
|
13205
|
+
}
|
|
13206
|
+
}
|
|
13207
|
+
const unusedDefinitions = definitions.filter((d) => !usedLabels.has(_normalizeLinkLabelMcp(d.label)));
|
|
13208
|
+
return {
|
|
13209
|
+
referenceCount: usages.length,
|
|
13210
|
+
definitionCount: definitions.length,
|
|
13211
|
+
resolved,
|
|
13212
|
+
unresolvedReferences,
|
|
13213
|
+
unusedDefinitions
|
|
13214
|
+
};
|
|
13215
|
+
}
|
|
13216
|
+
server.tool(
|
|
13217
|
+
"extract_markdown_link_references",
|
|
13218
|
+
'Extract every reference-style Markdown link ([text][ref], including the shortcut form [text][] and image references ![alt][ref]) alongside its [ref]: url "title" definition, then flag usages with no matching definition and definitions that no usage ever references.',
|
|
13219
|
+
{
|
|
13220
|
+
markdown: z.string().min(1).describe("The Markdown document text")
|
|
13221
|
+
},
|
|
13222
|
+
async ({ markdown }) => {
|
|
13223
|
+
const result = _extractMarkdownLinkReferencesMcp(markdown);
|
|
13224
|
+
if ("error" in result) return errText(result);
|
|
13225
|
+
return text(JSON.stringify(result, null, 2));
|
|
13226
|
+
}
|
|
13227
|
+
);
|
|
13228
|
+
var _PROMPT_TEMPLATE_VARIABLE_REGEX_MCP = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
|
|
13229
|
+
function _extractPromptTemplateVariablesMcp(template, providedVars) {
|
|
13230
|
+
const trimmed = template.trim();
|
|
13231
|
+
if (!trimmed) return { error: "Empty input" };
|
|
13232
|
+
const order = [];
|
|
13233
|
+
const counts = /* @__PURE__ */ new Map();
|
|
13234
|
+
for (const m of template.matchAll(_PROMPT_TEMPLATE_VARIABLE_REGEX_MCP)) {
|
|
13235
|
+
const name = m[1];
|
|
13236
|
+
if (!counts.has(name)) {
|
|
13237
|
+
counts.set(name, 0);
|
|
13238
|
+
order.push(name);
|
|
13239
|
+
}
|
|
13240
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
13241
|
+
}
|
|
13242
|
+
if (order.length === 0) return { error: "No {{variable}} placeholders found" };
|
|
13243
|
+
const variables = order.map((name) => ({ name, count: counts.get(name) ?? 0 }));
|
|
13244
|
+
const duplicates = order.filter((name) => (counts.get(name) ?? 0) > 1);
|
|
13245
|
+
let missing = [];
|
|
13246
|
+
const unused = [];
|
|
13247
|
+
if (providedVars !== void 0) {
|
|
13248
|
+
const providedSet = new Set(providedVars.map((v) => v.trim()).filter(Boolean));
|
|
13249
|
+
const usedSet = new Set(order);
|
|
13250
|
+
missing = order.filter((name) => !providedSet.has(name));
|
|
13251
|
+
const seenProvided = /* @__PURE__ */ new Set();
|
|
13252
|
+
for (const raw of providedVars) {
|
|
13253
|
+
const v = raw.trim();
|
|
13254
|
+
if (!v || usedSet.has(v) || seenProvided.has(v)) continue;
|
|
13255
|
+
seenProvided.add(v);
|
|
13256
|
+
unused.push(v);
|
|
13257
|
+
}
|
|
13258
|
+
}
|
|
13259
|
+
return { variableCount: order.length, variables, duplicates, missing, unused };
|
|
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
|
+
);
|
|
13523
|
+
server.tool(
|
|
13524
|
+
"extract_prompt_template_variables",
|
|
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.",
|
|
13526
|
+
{
|
|
13527
|
+
template: z.string().min(1).describe("The prompt template text containing {{variable}} placeholders"),
|
|
13528
|
+
providedVars: z.array(z.string()).optional().describe("Optional list of variable names already available, to cross-check against the template")
|
|
13529
|
+
},
|
|
13530
|
+
async ({ template, providedVars }) => {
|
|
13531
|
+
const result = _extractPromptTemplateVariablesMcp(template, providedVars);
|
|
13532
|
+
if ("error" in result) return errText(result);
|
|
13533
|
+
return text(JSON.stringify(result, null, 2));
|
|
13534
|
+
}
|
|
13535
|
+
);
|
|
13162
13536
|
var transport = new StdioServerTransport();
|
|
13163
13537
|
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.84.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",
|