@utilix-tech/mcp 0.75.0 → 0.77.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 +315 -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.77.0
|
|
6
|
+
- Add `read_pdf_outline` tool: walk a PDF's /Outlines bookmark tree (the Root -> Outlines -> First/Next/First chain) and list every bookmark's title, nesting level, and link target, in document order.
|
|
7
|
+
- Add `score_security_headers` tool: score a pasted block of raw HTTP response headers against commonly recommended security headers (CSP, HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-*), with an overall 0-100 score and A-F letter grade.
|
|
8
|
+
|
|
9
|
+
## 0.76.0
|
|
10
|
+
- Add `detect_wordy_phrases` tool: scan text against a curated table of wordy/redundant phrases ("due to the fact that", "in order to") and flag each occurrence with a suggested concise replacement, an occurrence count, and character offsets.
|
|
11
|
+
- Add `check_oxford_comma_consistency` tool: scan a document for comma-before-and/or usage in three-or-more-item lists and flag documents that mix Oxford comma styles.
|
|
12
|
+
|
|
5
13
|
## 0.75.0
|
|
6
14
|
- Add `verify_webhook_signature` tool: recompute an HMAC over a raw webhook payload and compare it to a provided signature, supporting the `sha256=` prefix style (Stripe/GitHub-style webhooks) plus raw hex or base64 encoding.
|
|
7
15
|
- Add `estimate_data_transfer_time` tool: estimate the theoretical time to transfer a file over a connection of a given speed, or the reverse: the bandwidth required to hit a target time.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @utilix-tech/mcp
|
|
2
2
|
|
|
3
|
-
MCP server exposing
|
|
3
|
+
MCP server exposing 249 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
|
|
|
@@ -147,6 +147,8 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
147
147
|
| `detect_passive_voice` | Flag sentences written in passive voice, e.g. "was written", "were approved by the team" |
|
|
148
148
|
| `score_readability` | Score text with Flesch Reading Ease, Flesch-Kincaid Grade Level, and Gunning Fog Index |
|
|
149
149
|
| `detect_filler_words` | Scan text for filler words, hedge phrases, and clichés with per-phrase counts and a density score |
|
|
150
|
+
| `detect_wordy_phrases` | Scan text against a curated table of wordy/redundant phrases and suggest a concise replacement for each occurrence |
|
|
151
|
+
| `check_oxford_comma_consistency` | Scan a document for comma-before-and/or usage in three-or-more-item lists and flag documents that mix Oxford comma styles |
|
|
150
152
|
| `calculate_string_distance` | Compute the Levenshtein (or Damerau) edit distance and normalized similarity between two strings |
|
|
151
153
|
| `analyze_sentence_lengths` | Split text into sentences and report a histogram of sentence lengths, plus mean, median, standard deviation, and a consistency label |
|
|
152
154
|
| `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 |
|
|
@@ -211,7 +213,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
211
213
|
| `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 |
|
|
212
214
|
| `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 |
|
|
213
215
|
|
|
214
|
-
### API / Network (
|
|
216
|
+
### API / Network (51 tools)
|
|
215
217
|
| Tool | Description |
|
|
216
218
|
|------|-------------|
|
|
217
219
|
| `decode_jwt` | Decode a JWT: header, payload, and expiry |
|
|
@@ -266,6 +268,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
266
268
|
| `parse_robots_meta_tag` | Parse an existing robots meta tag (or bare content value) into the same shape |
|
|
267
269
|
| `calculate_cookie_size_budget` | Parse a Cookie header's name=value pairs and compute each cookie's and the total header's byte size against RFC 6265's 4096-byte per-cookie guarantee and a configurable budget |
|
|
268
270
|
| `verify_webhook_signature` | Recompute an HMAC over a raw webhook payload and compare it to a provided signature, accepting a `sha256=` prefix style and matching against hex or base64 encoding |
|
|
271
|
+
| `score_security_headers` | Score a pasted block of raw HTTP response headers against commonly recommended security headers (CSP, HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-*), with an overall 0-100 score and A-F letter grade |
|
|
269
272
|
|
|
270
273
|
### Color (4 tools)
|
|
271
274
|
| Tool | Description |
|
|
@@ -373,11 +376,12 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
373
376
|
| `read_ico_info` | Read how many images a .ico file embeds and each one's width, height, color depth, and size from its ICONDIR header |
|
|
374
377
|
| `inspect_gif_frames` | Walk a GIF87a/89a file's block structure to report frame count, total animation duration, and loop count, without decoding any LZW-compressed pixel data |
|
|
375
378
|
|
|
376
|
-
### PDF (
|
|
379
|
+
### PDF (3 tools)
|
|
377
380
|
| Tool | Description |
|
|
378
381
|
|------|-------------|
|
|
379
382
|
| `read_pdf_metadata` | Read title, author, dates, page count, and encryption flag from a PDF's trailer/Info dictionary: no PDF rendering library required |
|
|
380
383
|
| `read_pdf_page_dimensions` | List each page's MediaBox width/height (points and inches), rotation, and orientation, walking the Root -> Pages -> Kids tree: no PDF rendering library required |
|
|
384
|
+
| `read_pdf_outline` | Walk a PDF's /Outlines bookmark tree (Root -> Outlines -> First/Next/First chain) and list every bookmark's title, nesting level, and link target, in document order: no PDF rendering library required |
|
|
381
385
|
|
|
382
386
|
### Audio (4 tools)
|
|
383
387
|
| Tool | Description |
|
package/dist/index.js
CHANGED
|
@@ -2972,6 +2972,84 @@ server.tool("read_pdf_page_dimensions", "List each page's MediaBox width/height
|
|
|
2972
2972
|
if ("error" in result) return errText(result);
|
|
2973
2973
|
return text(JSON.stringify(result, null, 2));
|
|
2974
2974
|
});
|
|
2975
|
+
function _parsePdfOutlineDest(dict) {
|
|
2976
|
+
const directRef = dict.match(/\/Dest\s*\[\s*(\d+)\s+(\d+)\s+R/);
|
|
2977
|
+
if (directRef) return { destType: "page", target: `${directRef[1]} ${directRef[2]} R` };
|
|
2978
|
+
const directName = dict.match(/\/Dest\s*\/([^\s/()<>[\]]+)/);
|
|
2979
|
+
if (directName) return { destType: "named", target: directName[1] };
|
|
2980
|
+
const directString = dict.match(/\/Dest\s*\(((?:\\.|[^\\()])*)\)/);
|
|
2981
|
+
if (directString) return { destType: "named", target: _decodePdfString(directString[1], false) };
|
|
2982
|
+
const actionUri = dict.match(/\/S\s*\/URI[\s\S]*?\/URI\s*\(((?:\\.|[^\\()])*)\)/);
|
|
2983
|
+
if (actionUri) return { destType: "uri", target: _decodePdfString(actionUri[1], false) };
|
|
2984
|
+
const actionGotoRef = dict.match(/\/S\s*\/GoTo[\s\S]*?\/D\s*\[\s*(\d+)\s+(\d+)\s+R/);
|
|
2985
|
+
if (actionGotoRef) return { destType: "page", target: `${actionGotoRef[1]} ${actionGotoRef[2]} R` };
|
|
2986
|
+
const actionGotoName = dict.match(/\/S\s*\/GoTo[\s\S]*?\/D\s*\/([^\s/()<>[\]]+)/);
|
|
2987
|
+
if (actionGotoName) return { destType: "named", target: actionGotoName[1] };
|
|
2988
|
+
return { destType: "none", target: null };
|
|
2989
|
+
}
|
|
2990
|
+
function _walkPdfOutlineChain(objMap, startRef, level, visited, out) {
|
|
2991
|
+
let ref = startRef;
|
|
2992
|
+
while (ref) {
|
|
2993
|
+
if (visited.has(ref)) break;
|
|
2994
|
+
visited.add(ref);
|
|
2995
|
+
const body = objMap.get(ref);
|
|
2996
|
+
if (!body) break;
|
|
2997
|
+
const titleRaw = _extractPdfField(body, "Title");
|
|
2998
|
+
const { destType, target } = _parsePdfOutlineDest(body);
|
|
2999
|
+
out.push({ title: titleRaw ?? "(untitled)", level, destType, target });
|
|
3000
|
+
const firstMatch = body.match(/\/First\s+(\d+)\s+(\d+)\s+R/);
|
|
3001
|
+
if (firstMatch) _walkPdfOutlineChain(objMap, `${firstMatch[1]} ${firstMatch[2]}`, level + 1, visited, out);
|
|
3002
|
+
const nextMatch = body.match(/\/Next\s+(\d+)\s+(\d+)\s+R/);
|
|
3003
|
+
ref = nextMatch ? `${nextMatch[1]} ${nextMatch[2]}` : null;
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
function _readPdfOutline(bytes) {
|
|
3007
|
+
if (bytes.length < 8) return { error: "File too small to be a PDF" };
|
|
3008
|
+
const latin1 = (b) => {
|
|
3009
|
+
let out2 = "";
|
|
3010
|
+
const CHUNK = 32768;
|
|
3011
|
+
for (let i = 0; i < b.length; i += CHUNK) out2 += String.fromCharCode(...b.subarray(i, i + CHUNK));
|
|
3012
|
+
return out2;
|
|
3013
|
+
};
|
|
3014
|
+
const header = latin1(bytes.subarray(0, 16));
|
|
3015
|
+
const versionMatch = header.match(/%PDF-(\d\.\d)/);
|
|
3016
|
+
if (!versionMatch) return { error: "Not a valid PDF file (missing %PDF header)" };
|
|
3017
|
+
const version = versionMatch[1];
|
|
3018
|
+
const text2 = latin1(bytes);
|
|
3019
|
+
const objBlocks = [...text2.matchAll(/(\d+)\s+(\d+)\s+obj([\s\S]*?)endobj/g)];
|
|
3020
|
+
const objMap = /* @__PURE__ */ new Map();
|
|
3021
|
+
for (const b of objBlocks) objMap.set(`${b[1]} ${b[2]}`, b[3]);
|
|
3022
|
+
let outlinesBody = null;
|
|
3023
|
+
const trailerMatches = [...text2.matchAll(/trailer\s*<<([\s\S]*?)>>/g)];
|
|
3024
|
+
if (trailerMatches.length > 0) {
|
|
3025
|
+
const trailer = trailerMatches[trailerMatches.length - 1][1];
|
|
3026
|
+
const rootRef = trailer.match(/\/Root\s+(\d+)\s+(\d+)\s+R/);
|
|
3027
|
+
if (rootRef) {
|
|
3028
|
+
const catalogBody = objMap.get(`${rootRef[1]} ${rootRef[2]}`);
|
|
3029
|
+
const outlinesRef = catalogBody?.match(/\/Outlines\s+(\d+)\s+(\d+)\s+R/);
|
|
3030
|
+
if (outlinesRef) outlinesBody = objMap.get(`${outlinesRef[1]} ${outlinesRef[2]}`) ?? null;
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
if (!outlinesBody) {
|
|
3034
|
+
for (const b of objBlocks) {
|
|
3035
|
+
if (/\/Type\s*\/Outlines\b/.test(b[3])) {
|
|
3036
|
+
outlinesBody = b[3];
|
|
3037
|
+
break;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
if (!outlinesBody) return { error: "No /Outlines bookmark tree found in this PDF" };
|
|
3042
|
+
const out = [];
|
|
3043
|
+
const firstMatch = outlinesBody.match(/\/First\s+(\d+)\s+(\d+)\s+R/);
|
|
3044
|
+
if (firstMatch) _walkPdfOutlineChain(objMap, `${firstMatch[1]} ${firstMatch[2]}`, 0, /* @__PURE__ */ new Set(), out);
|
|
3045
|
+
return { version, bookmarkCount: out.length, bookmarks: out };
|
|
3046
|
+
}
|
|
3047
|
+
server.tool("read_pdf_outline", "Walk a PDF's /Outlines bookmark tree (Root -> Outlines -> First/Next/First chain) and list every bookmark's title, nesting level, and link target, in document order: no PDF rendering library required. Does not resolve a page-ref target to a page number.", { pdf_base64: z.string().describe("Base64-encoded PDF bytes, optionally prefixed with a data: URL scheme") }, async ({ pdf_base64 }) => {
|
|
3048
|
+
const bytes = new Uint8Array(Buffer.from(pdf_base64.replace(/^data:.*;base64,/, ""), "base64"));
|
|
3049
|
+
const result = _readPdfOutline(bytes);
|
|
3050
|
+
if ("error" in result) return errText(result);
|
|
3051
|
+
return text(JSON.stringify(result, null, 2));
|
|
3052
|
+
});
|
|
2975
3053
|
var _EXIF_ORIENTATION_LABELS = {
|
|
2976
3054
|
1: "Normal",
|
|
2977
3055
|
2: "Mirrored horizontal",
|
|
@@ -4781,6 +4859,114 @@ server.tool(
|
|
|
4781
4859
|
return text(JSON.stringify(_detectFillerWordsMcp(input), null, 2));
|
|
4782
4860
|
}
|
|
4783
4861
|
);
|
|
4862
|
+
var _WORDY_PHRASES_MCP = [
|
|
4863
|
+
["due to the fact that", "because"],
|
|
4864
|
+
["in spite of the fact that", "although"],
|
|
4865
|
+
["despite the fact that", "although"],
|
|
4866
|
+
["in order to", "to"],
|
|
4867
|
+
["for the purpose of", "for"],
|
|
4868
|
+
["in the event that", "if"],
|
|
4869
|
+
["in the near future", "soon"],
|
|
4870
|
+
["at this point in time", "now"],
|
|
4871
|
+
["at the present time", "now"],
|
|
4872
|
+
["on a daily basis", "daily"],
|
|
4873
|
+
["on a regular basis", "regularly"],
|
|
4874
|
+
["in a timely manner", "promptly"],
|
|
4875
|
+
["with regard to", "regarding"],
|
|
4876
|
+
["with respect to", "regarding"],
|
|
4877
|
+
["in regards to", "regarding"],
|
|
4878
|
+
["with the exception of", "except"],
|
|
4879
|
+
["in close proximity to", "near"],
|
|
4880
|
+
["take into consideration", "consider"],
|
|
4881
|
+
["give consideration to", "consider"],
|
|
4882
|
+
["make a decision", "decide"],
|
|
4883
|
+
["is able to", "can"],
|
|
4884
|
+
["are able to", "can"],
|
|
4885
|
+
["a large number of", "many"],
|
|
4886
|
+
["a majority of", "most"],
|
|
4887
|
+
["each and every", "each"],
|
|
4888
|
+
["first and foremost", "first"],
|
|
4889
|
+
["last but not least", "finally"],
|
|
4890
|
+
["null and void", "void"],
|
|
4891
|
+
["whether or not", "whether"],
|
|
4892
|
+
["the reason why", "the reason"],
|
|
4893
|
+
["end result", "result"],
|
|
4894
|
+
["future plans", "plans"],
|
|
4895
|
+
["past history", "history"],
|
|
4896
|
+
["basic fundamentals", "fundamentals"],
|
|
4897
|
+
["absolutely essential", "essential"],
|
|
4898
|
+
["it is important to note that", "(often removable)"],
|
|
4899
|
+
["as a matter of fact", "in fact"],
|
|
4900
|
+
["in the process of", "(often removable)"]
|
|
4901
|
+
];
|
|
4902
|
+
function _wordyRegexMcp(phrase) {
|
|
4903
|
+
const tokens = phrase.split(/\s+/).map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
4904
|
+
return new RegExp("\\b" + tokens.join("\\s+") + "\\b", "gi");
|
|
4905
|
+
}
|
|
4906
|
+
function _detectWordyPhrasesMcp(input) {
|
|
4907
|
+
const totalWords = (input.match(/[A-Za-z0-9']+/g) ?? []).length;
|
|
4908
|
+
const matches = [];
|
|
4909
|
+
for (const [phrase, suggestion] of _WORDY_PHRASES_MCP) {
|
|
4910
|
+
const re = _wordyRegexMcp(phrase);
|
|
4911
|
+
const indices = [];
|
|
4912
|
+
let m;
|
|
4913
|
+
while ((m = re.exec(input)) !== null) {
|
|
4914
|
+
indices.push(m.index);
|
|
4915
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
4916
|
+
}
|
|
4917
|
+
if (indices.length > 0) matches.push({ phrase, suggestion, count: indices.length, indices });
|
|
4918
|
+
}
|
|
4919
|
+
matches.sort((a, b) => b.count - a.count || a.phrase.localeCompare(b.phrase));
|
|
4920
|
+
const totalMatches = matches.reduce((s, m) => s + m.count, 0);
|
|
4921
|
+
const density = totalWords > 0 ? Math.round(totalMatches / totalWords * 1e3) / 10 : 0;
|
|
4922
|
+
return { totalWords, totalMatches, density, matches };
|
|
4923
|
+
}
|
|
4924
|
+
server.tool(
|
|
4925
|
+
"detect_wordy_phrases",
|
|
4926
|
+
'Scan text against a curated table of wordy/redundant phrases ("due to the fact that", "in order to", "at this point in time") and flag each occurrence with a suggested concise replacement, an occurrence count, and character offsets. Dictionary-based heuristic: a flagged phrase is a candidate to tighten, not always wrong in context.',
|
|
4927
|
+
{ text: z.string().describe("The text to scan for wordy/redundant phrases") },
|
|
4928
|
+
async ({ text: input }) => {
|
|
4929
|
+
return text(JSON.stringify(_detectWordyPhrasesMcp(input), null, 2));
|
|
4930
|
+
}
|
|
4931
|
+
);
|
|
4932
|
+
var _OXFORD_CONJ_RE_MCP = /(,)?\s+\b(?:and|or)\b\s+/gi;
|
|
4933
|
+
function _checkOxfordCommaConsistencyMcp(input) {
|
|
4934
|
+
const instances = [];
|
|
4935
|
+
_OXFORD_CONJ_RE_MCP.lastIndex = 0;
|
|
4936
|
+
let m;
|
|
4937
|
+
while ((m = _OXFORD_CONJ_RE_MCP.exec(input)) !== null) {
|
|
4938
|
+
const matchStart = m.index;
|
|
4939
|
+
const hasCommaBefore = !!m[1];
|
|
4940
|
+
let clauseStart = -1;
|
|
4941
|
+
for (const ch of [".", "!", "?", "\n"]) {
|
|
4942
|
+
const idx = input.lastIndexOf(ch, matchStart - 1);
|
|
4943
|
+
if (idx > clauseStart) clauseStart = idx;
|
|
4944
|
+
}
|
|
4945
|
+
clauseStart += 1;
|
|
4946
|
+
const clause = input.slice(clauseStart, matchStart);
|
|
4947
|
+
const commaCount = (clause.match(/,/g) ?? []).length;
|
|
4948
|
+
if (commaCount < 1) continue;
|
|
4949
|
+
const snippetStart = Math.max(clauseStart, matchStart - 60);
|
|
4950
|
+
const afterConjEnd = _OXFORD_CONJ_RE_MCP.lastIndex;
|
|
4951
|
+
const tail = input.slice(afterConjEnd);
|
|
4952
|
+
const boundaryOffset = tail.search(/[.!?\n,]/);
|
|
4953
|
+
const snippetEnd = boundaryOffset === -1 ? Math.min(input.length, afterConjEnd + 40) : afterConjEnd + boundaryOffset;
|
|
4954
|
+
const snippet = input.slice(snippetStart, snippetEnd).trim();
|
|
4955
|
+
instances.push({ snippet, hasOxfordComma: hasCommaBefore, index: matchStart });
|
|
4956
|
+
}
|
|
4957
|
+
const withOxfordComma = instances.filter((i) => i.hasOxfordComma).length;
|
|
4958
|
+
const withoutOxfordComma = instances.length - withOxfordComma;
|
|
4959
|
+
const consistent = withOxfordComma === 0 || withoutOxfordComma === 0;
|
|
4960
|
+
return { totalLists: instances.length, withOxfordComma, withoutOxfordComma, consistent, instances };
|
|
4961
|
+
}
|
|
4962
|
+
server.tool(
|
|
4963
|
+
"check_oxford_comma_consistency",
|
|
4964
|
+
"Scan a document for comma-before-and/or usage in three-or-more-item lists and flag inconsistency: some lists using the Oxford (serial) comma while others in the same document don't. A heuristic over punctuation and sentence boundaries, not a full grammar parser.",
|
|
4965
|
+
{ text: z.string().describe("The document to scan for Oxford comma consistency") },
|
|
4966
|
+
async ({ text: input }) => {
|
|
4967
|
+
return text(JSON.stringify(_checkOxfordCommaConsistencyMcp(input), null, 2));
|
|
4968
|
+
}
|
|
4969
|
+
);
|
|
4784
4970
|
function _calculatePercentageChangeMcp(oldValue, newValue) {
|
|
4785
4971
|
if (!Number.isFinite(oldValue)) return { error: "Original value must be a finite number" };
|
|
4786
4972
|
if (!Number.isFinite(newValue)) return { error: "New value must be a finite number" };
|
|
@@ -11623,6 +11809,135 @@ server.tool(
|
|
|
11623
11809
|
return text(JSON.stringify(result, null, 2));
|
|
11624
11810
|
}
|
|
11625
11811
|
);
|
|
11812
|
+
function _parseRawHeaderBlockMcp(raw) {
|
|
11813
|
+
const map = /* @__PURE__ */ new Map();
|
|
11814
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
11815
|
+
const trimmed = line.trim();
|
|
11816
|
+
if (!trimmed) continue;
|
|
11817
|
+
const idx = trimmed.indexOf(":");
|
|
11818
|
+
if (idx === -1) continue;
|
|
11819
|
+
const name = trimmed.slice(0, idx).trim().toLowerCase();
|
|
11820
|
+
const value = trimmed.slice(idx + 1).trim();
|
|
11821
|
+
if (!map.has(name)) map.set(name, value);
|
|
11822
|
+
}
|
|
11823
|
+
return map;
|
|
11824
|
+
}
|
|
11825
|
+
var _SECURITY_HEADER_CHECKS_MCP = [
|
|
11826
|
+
{
|
|
11827
|
+
header: "Content-Security-Policy",
|
|
11828
|
+
evaluate: (v) => {
|
|
11829
|
+
if (!v) return { status: "missing", note: "Without a CSP, an injected script runs with full page privileges." };
|
|
11830
|
+
if (/unsafe-inline|unsafe-eval/i.test(v)) return { status: "warning", note: "Contains unsafe-inline or unsafe-eval, which weakens XSS protection." };
|
|
11831
|
+
return { status: "pass", note: "Present. Use the CSP Header Builder tool to validate its directives in depth." };
|
|
11832
|
+
}
|
|
11833
|
+
},
|
|
11834
|
+
{
|
|
11835
|
+
header: "Strict-Transport-Security",
|
|
11836
|
+
evaluate: (v) => {
|
|
11837
|
+
if (!v) return { status: "missing", note: "Without HSTS, a user's first visit (and any later downgrade) can be sent over plain HTTP." };
|
|
11838
|
+
const maxAgeMatch = v.match(/max-age\s*=\s*(\d+)/i);
|
|
11839
|
+
if (!maxAgeMatch) return { status: "warning", note: "Present but missing a max-age directive." };
|
|
11840
|
+
const maxAge = Number(maxAgeMatch[1]);
|
|
11841
|
+
if (maxAge < 31536e3) return { status: "warning", note: "max-age is below the recommended 1 year (31536000 seconds)." };
|
|
11842
|
+
return { status: "pass", note: "Present with a max-age of at least 1 year." };
|
|
11843
|
+
}
|
|
11844
|
+
},
|
|
11845
|
+
{
|
|
11846
|
+
header: "X-Content-Type-Options",
|
|
11847
|
+
evaluate: (v) => {
|
|
11848
|
+
if (!v) return { status: "missing", note: "Without this header, browsers may MIME-sniff a response into an unintended content type." };
|
|
11849
|
+
if (v.trim().toLowerCase() !== "nosniff") return { status: "warning", note: `"nosniff" is the only valid value; this value won't take effect.` };
|
|
11850
|
+
return { status: "pass", note: "Set to the only valid value, nosniff." };
|
|
11851
|
+
}
|
|
11852
|
+
},
|
|
11853
|
+
{
|
|
11854
|
+
header: "X-Frame-Options",
|
|
11855
|
+
evaluate: (v) => {
|
|
11856
|
+
if (!v) return { status: "missing", note: "Without this (or a CSP frame-ancestors directive), the page can be framed by another site for clickjacking." };
|
|
11857
|
+
const upper = v.trim().toUpperCase();
|
|
11858
|
+
if (upper === "DENY" || upper === "SAMEORIGIN") return { status: "pass", note: `Framing restricted (${upper}).` };
|
|
11859
|
+
if (upper.startsWith("ALLOW-FROM")) return { status: "warning", note: "ALLOW-FROM is deprecated and ignored by most modern browsers." };
|
|
11860
|
+
return { status: "warning", note: "Unrecognized value." };
|
|
11861
|
+
}
|
|
11862
|
+
},
|
|
11863
|
+
{
|
|
11864
|
+
header: "Referrer-Policy",
|
|
11865
|
+
evaluate: (v) => {
|
|
11866
|
+
if (!v) return { status: "missing", note: "Without this header, the browser's default referrer behavior applies, which can leak full URLs to third parties." };
|
|
11867
|
+
if (v.trim().toLowerCase() === "unsafe-url") return { status: "warning", note: "unsafe-url leaks the full URL, including path and query string, to every destination." };
|
|
11868
|
+
return { status: "pass", note: "Present." };
|
|
11869
|
+
}
|
|
11870
|
+
},
|
|
11871
|
+
{
|
|
11872
|
+
header: "Permissions-Policy",
|
|
11873
|
+
evaluate: (v) => {
|
|
11874
|
+
if (!v) return { status: "missing", note: "Without this header, embedded content can use powerful browser features (camera, geolocation, etc.) unless restricted another way." };
|
|
11875
|
+
return { status: "pass", note: "Present." };
|
|
11876
|
+
}
|
|
11877
|
+
},
|
|
11878
|
+
{
|
|
11879
|
+
header: "Cross-Origin-Opener-Policy",
|
|
11880
|
+
evaluate: (v) => {
|
|
11881
|
+
if (!v) return { status: "missing", note: "Without COOP, this page can be put in the same browsing context group as a malicious opener window." };
|
|
11882
|
+
const lower = v.trim().toLowerCase();
|
|
11883
|
+
if (lower === "same-origin") return { status: "pass", note: "Set to same-origin, the strongest isolation value." };
|
|
11884
|
+
return { status: "warning", note: `Set to "${v.trim()}", which is weaker than same-origin.` };
|
|
11885
|
+
}
|
|
11886
|
+
},
|
|
11887
|
+
{
|
|
11888
|
+
header: "Cross-Origin-Embedder-Policy",
|
|
11889
|
+
evaluate: (v) => {
|
|
11890
|
+
if (!v) return { status: "missing", note: "Required alongside COOP for cross-origin isolation (e.g. to use SharedArrayBuffer)." };
|
|
11891
|
+
const lower = v.trim().toLowerCase();
|
|
11892
|
+
if (lower === "require-corp" || lower === "credentialless") return { status: "pass", note: `Set to ${lower}.` };
|
|
11893
|
+
return { status: "warning", note: `Unrecognized value "${v.trim()}".` };
|
|
11894
|
+
}
|
|
11895
|
+
},
|
|
11896
|
+
{
|
|
11897
|
+
header: "Cross-Origin-Resource-Policy",
|
|
11898
|
+
evaluate: (v) => {
|
|
11899
|
+
if (!v) return { status: "missing", note: "Without CORP, this resource can be loaded cross-origin by any site that knows its URL." };
|
|
11900
|
+
const lower = v.trim().toLowerCase();
|
|
11901
|
+
if (lower === "same-origin" || lower === "same-site") return { status: "pass", note: `Set to ${lower}.` };
|
|
11902
|
+
if (lower === "cross-origin") return { status: "warning", note: "Set to cross-origin, which allows any site to load this resource." };
|
|
11903
|
+
return { status: "warning", note: `Unrecognized value "${v.trim()}".` };
|
|
11904
|
+
}
|
|
11905
|
+
}
|
|
11906
|
+
];
|
|
11907
|
+
function _scoreSecurityHeadersMcp(rawHeaders) {
|
|
11908
|
+
if (typeof rawHeaders !== "string" || !rawHeaders.trim()) {
|
|
11909
|
+
return { error: 'Paste one or more response headers, one per line in "Name: value" form' };
|
|
11910
|
+
}
|
|
11911
|
+
const headerMap = _parseRawHeaderBlockMcp(rawHeaders);
|
|
11912
|
+
if (headerMap.size === 0) {
|
|
11913
|
+
return { error: 'Could not parse any "Name: value" header lines from the input' };
|
|
11914
|
+
}
|
|
11915
|
+
const checks = _SECURITY_HEADER_CHECKS_MCP.map((def) => {
|
|
11916
|
+
const value = headerMap.get(def.header.toLowerCase()) ?? null;
|
|
11917
|
+
const { status, note } = def.evaluate(value);
|
|
11918
|
+
return { header: def.header, status, value, note };
|
|
11919
|
+
});
|
|
11920
|
+
const passCount = checks.filter((c) => c.status === "pass").length;
|
|
11921
|
+
const warningCount = checks.filter((c) => c.status === "warning").length;
|
|
11922
|
+
const missingCount = checks.filter((c) => c.status === "missing").length;
|
|
11923
|
+
const totalChecks = checks.length;
|
|
11924
|
+
const points = passCount + warningCount * 0.5;
|
|
11925
|
+
const score = Math.round(points / totalChecks * 100);
|
|
11926
|
+
const grade = score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
|
|
11927
|
+
return { checks, score, grade, passCount, warningCount, missingCount, totalChecks };
|
|
11928
|
+
}
|
|
11929
|
+
server.tool(
|
|
11930
|
+
"score_security_headers",
|
|
11931
|
+
'Parse a pasted block of raw HTTP response headers (one "Name: value" pair per line) and score it against commonly recommended security headers (CSP, HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, and the three Cross-Origin-* isolation headers), with an overall 0-100 score and A-F letter grade. Evaluates the header text you give it, not a live request.',
|
|
11932
|
+
{
|
|
11933
|
+
headers: z.string().describe('Raw response headers, one "Name: value" pair per line')
|
|
11934
|
+
},
|
|
11935
|
+
async ({ headers }) => {
|
|
11936
|
+
const result = _scoreSecurityHeadersMcp(headers);
|
|
11937
|
+
if ("error" in result) return errText(result);
|
|
11938
|
+
return text(JSON.stringify(result, null, 2));
|
|
11939
|
+
}
|
|
11940
|
+
);
|
|
11626
11941
|
function _roundMoneyPrepaymentPenaltyMcp(n) {
|
|
11627
11942
|
return Math.round(n * 100) / 100;
|
|
11628
11943
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@utilix-tech/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.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",
|