@utilix-tech/mcp 0.96.0 → 0.98.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 +399 -0
- package/package.json +2 -2
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.98.0
|
|
6
|
+
- Add `convert_timecode` tool: convert between a frame count, SMPTE timecode (HH:MM:SS:FF), and seconds at a given frame rate, handling NTSC drop-frame timecode's skipped frame numbers correctly.
|
|
7
|
+
- Add `validate_geojson` tool: validate a GeoJSON document against RFC 7946's structural rules, geometry type/coordinates shape, polygon ring closure and winding, and a FeatureCollection's required fields.
|
|
8
|
+
|
|
9
|
+
## 0.97.0
|
|
10
|
+
- Add `calculate_position_size` tool: compute a trade's position size from account size, risk percent per trade, entry price, and stop-loss price, plus the risk/reward ratio against an optional target price.
|
|
11
|
+
- Add `detect_weasel_words` tool: scan text for weasel words, vague hedging phrases that imply evidence or consensus without naming a checkable source, like "studies show" or "many experts believe".
|
|
12
|
+
|
|
5
13
|
## 0.96.0
|
|
6
14
|
- Add `validate_routing_number` tool: validate a 9-digit US bank routing number via its weighted mod-10 checksum, identifying the routing number's type and Federal Reserve district from its first two digits.
|
|
7
15
|
- Add `audit_markdown_image_alt_text` tool: scan a Markdown document's inline images and flag alt text accessibility smells, missing/empty alt text, generic placeholder words, a redundant "image of" prefix, and alt text that's just the image's own filename.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @utilix-tech/mcp
|
|
2
2
|
|
|
3
|
-
MCP server exposing
|
|
3
|
+
MCP server exposing 289 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
|
|
|
@@ -89,6 +89,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
89
89
|
| `diff_json_patch` | Compare two JSON documents and generate an RFC 6902 JSON Patch operation list |
|
|
90
90
|
| `apply_json_patch` | Apply a sequence of RFC 6902 JSON Patch operations (add, remove, replace, move, copy, test) to a target document |
|
|
91
91
|
| `validate_json` | Validate JSON against a JSON Schema |
|
|
92
|
+
| `validate_geojson` | Validate a GeoJSON document against RFC 7946's structural rules: geometry type/coordinates shape, polygon ring closure and winding, and a FeatureCollection's required fields |
|
|
92
93
|
| `yaml_to_json` | Convert YAML to JSON |
|
|
93
94
|
| `json_to_yaml` | Convert JSON to YAML |
|
|
94
95
|
| `json_to_typescript` | Generate TypeScript interfaces from JSON |
|
|
@@ -134,7 +135,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
134
135
|
| `generate_random_data` | Generate random mock data: names, emails, UUIDs, IPs, etc. |
|
|
135
136
|
| `generate_csp_nonce` | Generate a cryptographically random CSP nonce with ready-to-paste header directive and script/style tag snippets |
|
|
136
137
|
|
|
137
|
-
### Text (
|
|
138
|
+
### Text (19 tools)
|
|
138
139
|
| Tool | Description |
|
|
139
140
|
|------|-------------|
|
|
140
141
|
| `convert_case` | Convert text case: camelCase, snake_case, PascalCase, etc. |
|
|
@@ -157,6 +158,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
157
158
|
| `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
159
|
| `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 |
|
|
159
160
|
| `check_acronym_expansion` | Scan text for acronyms and flag any that are used before being expanded on first mention, or never expanded at all |
|
|
161
|
+
| `detect_weasel_words` | Scan text for weasel words: vague hedging phrases that imply evidence or consensus without naming a checkable source, like "studies show" or "many experts believe" |
|
|
160
162
|
|
|
161
163
|
### Time (6 tools)
|
|
162
164
|
| Tool | Description |
|
|
@@ -169,7 +171,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
169
171
|
| `convert_timezone` | Convert a date/time between timezones |
|
|
170
172
|
| `convert_iso8601_duration` | Parse an ISO 8601 duration into components and total seconds, or format seconds into a duration string |
|
|
171
173
|
|
|
172
|
-
### Units (
|
|
174
|
+
### Units (53 tools)
|
|
173
175
|
| Tool | Description |
|
|
174
176
|
|------|-------------|
|
|
175
177
|
| `convert_bytes` | Convert between B, KB, MB, GB, TB, KiB, MiB, GiB, TiB |
|
|
@@ -193,6 +195,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
193
195
|
| `calculate_break_even` | Compute the break-even unit count and revenue from fixed costs, variable cost per unit, and price per unit |
|
|
194
196
|
| `calculate_video_bitrate` | Calculate a video's file size from a bitrate and duration, or the bitrate needed to hit a target file size |
|
|
195
197
|
| `calculate_rule_of_72` | Estimate years to double an investment from a rate, or the rate needed to double in a given number of years, via the Rule of 72 shortcut and the exact logarithmic result |
|
|
198
|
+
| `convert_timecode` | Convert between a frame count, SMPTE timecode, and seconds at a given frame rate, handling NTSC drop-frame timecode's skipped frame numbers correctly |
|
|
196
199
|
| `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 |
|
|
197
200
|
| `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 |
|
|
198
201
|
| `calculate_annuity_payout` | Solve the standard annuity formula in either direction: the periodic payout a lump sum funds, or the lump sum required to fund a target payout, for an ordinary or annuity-due structure |
|
|
@@ -224,6 +227,7 @@ claude mcp add utilix -- npx -y @utilix-tech/mcp
|
|
|
224
227
|
| `calculate_sales_commission` | Compute a sales commission from either a flat percent rate applied to the whole sale amount, or a tiered (marginal) rate structure where each tier applies only to the slice of sales within it, plus an optional base salary added to the total payout |
|
|
225
228
|
| `intersect_semver_ranges` | Determine whether two semver ranges overlap and, if so, the intersecting range, supporting the same caret/tilde/comparison-operator/exact-version syntax as the semver tool |
|
|
226
229
|
| `validate_routing_number` | Validate a 9-digit US bank routing number (ABA/RTN) via its weighted mod-10 checksum, and identify its routing number type and Federal Reserve district |
|
|
230
|
+
| `calculate_position_size` | Compute a trade's position size from account size, risk percent per trade, entry price, and stop-loss price, plus the risk/reward ratio against an optional target price |
|
|
227
231
|
|
|
228
232
|
### API / Network (55 tools)
|
|
229
233
|
| Tool | Description |
|
package/dist/index.js
CHANGED
|
@@ -6003,6 +6003,295 @@ server.tool(
|
|
|
6003
6003
|
return text(JSON.stringify(result, null, 2));
|
|
6004
6004
|
}
|
|
6005
6005
|
);
|
|
6006
|
+
var _TIMECODE_NOMINAL_FPS_MCP = {
|
|
6007
|
+
"23.976": 24,
|
|
6008
|
+
"24": 24,
|
|
6009
|
+
"25": 25,
|
|
6010
|
+
"29.97": 30,
|
|
6011
|
+
"30": 30,
|
|
6012
|
+
"50": 50,
|
|
6013
|
+
"59.94": 60,
|
|
6014
|
+
"60": 60
|
|
6015
|
+
};
|
|
6016
|
+
function _framesToPlainTimecodeMcp(frameNumber, nominalFps) {
|
|
6017
|
+
const framesPerDay = nominalFps * 86400;
|
|
6018
|
+
const fn = (frameNumber % framesPerDay + framesPerDay) % framesPerDay;
|
|
6019
|
+
const frames = fn % nominalFps;
|
|
6020
|
+
const totalSeconds = Math.floor(fn / nominalFps);
|
|
6021
|
+
return {
|
|
6022
|
+
hours: Math.floor(totalSeconds / 3600) % 24,
|
|
6023
|
+
minutes: Math.floor(totalSeconds / 60) % 60,
|
|
6024
|
+
seconds: totalSeconds % 60,
|
|
6025
|
+
frames
|
|
6026
|
+
};
|
|
6027
|
+
}
|
|
6028
|
+
function _plainTimecodeToFramesMcp(hh, mm, ss, ff, nominalFps) {
|
|
6029
|
+
return nominalFps * 3600 * hh + nominalFps * 60 * mm + nominalFps * ss + ff;
|
|
6030
|
+
}
|
|
6031
|
+
function _framesToDropFrameTimecodeMcp(frameNumber, nominalFps, frameRate) {
|
|
6032
|
+
const dropFrames = nominalFps === 30 ? 2 : 4;
|
|
6033
|
+
const framesPer24Hours = Math.round(frameRate * 86400);
|
|
6034
|
+
const framesPer10Minutes = Math.round(frameRate * 600);
|
|
6035
|
+
const framesPerMinute = nominalFps * 60 - dropFrames;
|
|
6036
|
+
const fn = (frameNumber % framesPer24Hours + framesPer24Hours) % framesPer24Hours;
|
|
6037
|
+
const d = Math.floor(fn / framesPer10Minutes);
|
|
6038
|
+
const m = fn % framesPer10Minutes;
|
|
6039
|
+
let frameNumberOut = fn + 9 * dropFrames * d;
|
|
6040
|
+
if (m > dropFrames) {
|
|
6041
|
+
frameNumberOut += dropFrames * Math.floor((m - dropFrames) / framesPerMinute);
|
|
6042
|
+
}
|
|
6043
|
+
const frames = frameNumberOut % nominalFps;
|
|
6044
|
+
const totalSeconds = Math.floor(frameNumberOut / nominalFps);
|
|
6045
|
+
return {
|
|
6046
|
+
hours: Math.floor(totalSeconds / 3600) % 24,
|
|
6047
|
+
minutes: Math.floor(totalSeconds / 60) % 60,
|
|
6048
|
+
seconds: totalSeconds % 60,
|
|
6049
|
+
frames
|
|
6050
|
+
};
|
|
6051
|
+
}
|
|
6052
|
+
function _dropFrameTimecodeToFramesMcp(hh, mm, ss, ff, nominalFps) {
|
|
6053
|
+
const dropFrames = nominalFps === 30 ? 2 : 4;
|
|
6054
|
+
const totalMinutes = 60 * hh + mm;
|
|
6055
|
+
return nominalFps * 3600 * hh + nominalFps * 60 * mm + nominalFps * ss + ff - dropFrames * (totalMinutes - Math.floor(totalMinutes / 10));
|
|
6056
|
+
}
|
|
6057
|
+
function _formatTimecodeMcp(parts, dropFrame) {
|
|
6058
|
+
const p2 = (n) => String(n).padStart(2, "0");
|
|
6059
|
+
const sep = dropFrame ? ";" : ":";
|
|
6060
|
+
return `${p2(parts.hours)}:${p2(parts.minutes)}:${p2(parts.seconds)}${sep}${p2(parts.frames)}`;
|
|
6061
|
+
}
|
|
6062
|
+
function _convertTimecodeMcp(mode, frameRate, dropFrame, frameCount, timecode) {
|
|
6063
|
+
const nominalFps = _TIMECODE_NOMINAL_FPS_MCP[String(frameRate)] ?? null;
|
|
6064
|
+
if (nominalFps === null) return { error: "frameRate must be one of 23.976, 24, 25, 29.97, 30, 50, 59.94, 60" };
|
|
6065
|
+
if (dropFrame && frameRate !== 29.97 && frameRate !== 59.94) {
|
|
6066
|
+
return { error: "dropFrame is only valid for frameRate 29.97 or 59.94" };
|
|
6067
|
+
}
|
|
6068
|
+
if (mode === "frames-to-timecode") {
|
|
6069
|
+
if (typeof frameCount !== "number" || !Number.isFinite(frameCount) || frameCount < 0 || Math.floor(frameCount) !== frameCount) {
|
|
6070
|
+
return { error: 'frameCount must be a non-negative integer for mode "frames-to-timecode"' };
|
|
6071
|
+
}
|
|
6072
|
+
const parts = dropFrame ? _framesToDropFrameTimecodeMcp(frameCount, nominalFps, frameRate) : _framesToPlainTimecodeMcp(frameCount, nominalFps);
|
|
6073
|
+
return {
|
|
6074
|
+
mode,
|
|
6075
|
+
frameRate,
|
|
6076
|
+
dropFrame,
|
|
6077
|
+
frameCount,
|
|
6078
|
+
timecode: _formatTimecodeMcp(parts, dropFrame),
|
|
6079
|
+
seconds: Math.round(frameCount / frameRate * 1e6) / 1e6
|
|
6080
|
+
};
|
|
6081
|
+
}
|
|
6082
|
+
if (mode === "timecode-to-frames") {
|
|
6083
|
+
if (typeof timecode !== "string") return { error: 'timecode must be a string for mode "timecode-to-frames"' };
|
|
6084
|
+
const match = /^(\d{2}):(\d{2}):(\d{2})[:;](\d{2})$/.exec(timecode.trim());
|
|
6085
|
+
if (!match) return { error: "timecode must be in the form HH:MM:SS:FF (or HH:MM:SS;FF)" };
|
|
6086
|
+
const [, hhS, mmS, ssS, ffS] = match;
|
|
6087
|
+
const hh = Number(hhS), mm = Number(mmS), ss = Number(ssS), ff = Number(ffS);
|
|
6088
|
+
if (hh > 23) return { error: "hours must be 00-23" };
|
|
6089
|
+
if (mm > 59) return { error: "minutes must be 00-59" };
|
|
6090
|
+
if (ss > 59) return { error: "seconds must be 00-59" };
|
|
6091
|
+
if (ff >= nominalFps) return { error: `frames must be 00-${nominalFps - 1} at this frame rate` };
|
|
6092
|
+
if (dropFrame && ff <= 1 && ss === 0 && mm % 10 !== 0) {
|
|
6093
|
+
return { error: `frame ${ffS} at ${hhS}:${mmS}:${ssS} does not exist in drop-frame timecode: frames 00 and 01 are dropped at the start of every minute except every 10th minute` };
|
|
6094
|
+
}
|
|
6095
|
+
const frameCountOut = dropFrame ? _dropFrameTimecodeToFramesMcp(hh, mm, ss, ff, nominalFps) : _plainTimecodeToFramesMcp(hh, mm, ss, ff, nominalFps);
|
|
6096
|
+
return {
|
|
6097
|
+
mode,
|
|
6098
|
+
frameRate,
|
|
6099
|
+
dropFrame,
|
|
6100
|
+
frameCount: frameCountOut,
|
|
6101
|
+
timecode: _formatTimecodeMcp({ hours: hh, minutes: mm, seconds: ss, frames: ff }, dropFrame),
|
|
6102
|
+
seconds: Math.round(frameCountOut / frameRate * 1e6) / 1e6
|
|
6103
|
+
};
|
|
6104
|
+
}
|
|
6105
|
+
return { error: 'mode must be "frames-to-timecode" or "timecode-to-frames"' };
|
|
6106
|
+
}
|
|
6107
|
+
server.tool(
|
|
6108
|
+
"convert_timecode",
|
|
6109
|
+
"Convert between a frame count, SMPTE timecode (HH:MM:SS:FF, or HH:MM:SS;FF for drop-frame), and seconds at a given frame rate. Handles NTSC drop-frame timecode (29.97/59.94 fps) correctly via the standard SMPTE 12M algorithm, which skips frame numbers 00 and 01 at the start of every minute except every 10th minute so the nominal 30/60 fps digit count stays in sync with the true 29.97/59.94 fps signal. Pure arithmetic; no video file or container is read.",
|
|
6110
|
+
{
|
|
6111
|
+
mode: z.enum(["frames-to-timecode", "timecode-to-frames"]).describe("frames-to-timecode requires frameCount; timecode-to-frames requires timecode"),
|
|
6112
|
+
frameRate: z.number().describe("One of 23.976, 24, 25, 29.97, 30, 50, 59.94, 60"),
|
|
6113
|
+
dropFrame: z.boolean().default(false).describe("Only valid for frameRate 29.97 or 59.94"),
|
|
6114
|
+
frameCount: z.number().optional().describe('Required for mode "frames-to-timecode"'),
|
|
6115
|
+
timecode: z.string().optional().describe('Required for mode "timecode-to-frames"; "HH:MM:SS:FF" or "HH:MM:SS;FF"')
|
|
6116
|
+
},
|
|
6117
|
+
async ({ mode, frameRate, dropFrame, frameCount, timecode }) => {
|
|
6118
|
+
const result = _convertTimecodeMcp(mode, frameRate, dropFrame, frameCount, timecode);
|
|
6119
|
+
if ("error" in result) return errText(result);
|
|
6120
|
+
return text(JSON.stringify(result, null, 2));
|
|
6121
|
+
}
|
|
6122
|
+
);
|
|
6123
|
+
var _GEOJSON_GEOMETRY_TYPES_MCP = /* @__PURE__ */ new Set([
|
|
6124
|
+
"Point",
|
|
6125
|
+
"MultiPoint",
|
|
6126
|
+
"LineString",
|
|
6127
|
+
"MultiLineString",
|
|
6128
|
+
"Polygon",
|
|
6129
|
+
"MultiPolygon",
|
|
6130
|
+
"GeometryCollection"
|
|
6131
|
+
]);
|
|
6132
|
+
function _isGeoJsonNumberMcp(v) {
|
|
6133
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
6134
|
+
}
|
|
6135
|
+
function _isGeoJsonPositionMcp(v) {
|
|
6136
|
+
return Array.isArray(v) && (v.length === 2 || v.length === 3) && v.every(_isGeoJsonNumberMcp);
|
|
6137
|
+
}
|
|
6138
|
+
function _geoJsonPositionsEqualMcp(a, b) {
|
|
6139
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
6140
|
+
}
|
|
6141
|
+
function _geoJsonRingSignedAreaMcp(ring) {
|
|
6142
|
+
let sum = 0;
|
|
6143
|
+
for (let i = 0; i < ring.length - 1; i++) {
|
|
6144
|
+
const [x1, y1] = ring[i];
|
|
6145
|
+
const [x2, y2] = ring[i + 1];
|
|
6146
|
+
sum += x1 * y2 - x2 * y1;
|
|
6147
|
+
}
|
|
6148
|
+
return sum / 2;
|
|
6149
|
+
}
|
|
6150
|
+
function _validateGeoJsonPolygonCoordsMcp(coords, path, errors, warnings) {
|
|
6151
|
+
if (!Array.isArray(coords) || coords.length === 0) {
|
|
6152
|
+
errors.push({ path, message: "Polygon coordinates must be a non-empty array of linear rings" });
|
|
6153
|
+
return;
|
|
6154
|
+
}
|
|
6155
|
+
coords.forEach((ring, i) => {
|
|
6156
|
+
const ringPath = `${path}[${i}]`;
|
|
6157
|
+
if (!Array.isArray(ring) || ring.length < 4 || !ring.every(_isGeoJsonPositionMcp)) {
|
|
6158
|
+
errors.push({ path: ringPath, message: "each linear ring must have at least 4 positions" });
|
|
6159
|
+
return;
|
|
6160
|
+
}
|
|
6161
|
+
const first = ring[0];
|
|
6162
|
+
const last = ring[ring.length - 1];
|
|
6163
|
+
if (!_geoJsonPositionsEqualMcp(first, last)) {
|
|
6164
|
+
errors.push({ path: ringPath, message: "the first and last positions of a linear ring must be identical (the ring must be closed)" });
|
|
6165
|
+
return;
|
|
6166
|
+
}
|
|
6167
|
+
const area = _geoJsonRingSignedAreaMcp(ring);
|
|
6168
|
+
const isExterior = i === 0;
|
|
6169
|
+
if (isExterior && area < 0) {
|
|
6170
|
+
warnings.push({ path: ringPath, message: "exterior ring winds clockwise; RFC 7946 recommends counterclockwise for exterior rings (right-hand rule)" });
|
|
6171
|
+
} else if (!isExterior && area > 0) {
|
|
6172
|
+
warnings.push({ path: ringPath, message: "interior ring (hole) winds counterclockwise; RFC 7946 recommends clockwise for interior rings (right-hand rule)" });
|
|
6173
|
+
}
|
|
6174
|
+
});
|
|
6175
|
+
}
|
|
6176
|
+
function _validateGeoJsonGeometryMcp(geom, path, errors, warnings) {
|
|
6177
|
+
if (geom === null) return;
|
|
6178
|
+
if (typeof geom !== "object" || Array.isArray(geom)) {
|
|
6179
|
+
errors.push({ path, message: "geometry must be an object or null" });
|
|
6180
|
+
return;
|
|
6181
|
+
}
|
|
6182
|
+
const type = geom.type;
|
|
6183
|
+
if (typeof type !== "string" || !_GEOJSON_GEOMETRY_TYPES_MCP.has(type)) {
|
|
6184
|
+
errors.push({ path: `${path}.type`, message: `geometry type must be one of ${[..._GEOJSON_GEOMETRY_TYPES_MCP].join(", ")}` });
|
|
6185
|
+
return;
|
|
6186
|
+
}
|
|
6187
|
+
if (type === "GeometryCollection") {
|
|
6188
|
+
if (!Array.isArray(geom.geometries)) {
|
|
6189
|
+
errors.push({ path: `${path}.geometries`, message: 'GeometryCollection must have a "geometries" array' });
|
|
6190
|
+
return;
|
|
6191
|
+
}
|
|
6192
|
+
geom.geometries.forEach((g, i) => _validateGeoJsonGeometryMcp(g, `${path}.geometries[${i}]`, errors, warnings));
|
|
6193
|
+
return;
|
|
6194
|
+
}
|
|
6195
|
+
const coords = geom.coordinates;
|
|
6196
|
+
const coordPath = `${path}.coordinates`;
|
|
6197
|
+
switch (type) {
|
|
6198
|
+
case "Point":
|
|
6199
|
+
if (!_isGeoJsonPositionMcp(coords)) errors.push({ path: coordPath, message: "Point coordinates must be an array of 2 or 3 numbers" });
|
|
6200
|
+
break;
|
|
6201
|
+
case "MultiPoint":
|
|
6202
|
+
if (!Array.isArray(coords) || !coords.every(_isGeoJsonPositionMcp)) {
|
|
6203
|
+
errors.push({ path: coordPath, message: "MultiPoint coordinates must be an array of positions" });
|
|
6204
|
+
}
|
|
6205
|
+
break;
|
|
6206
|
+
case "LineString":
|
|
6207
|
+
if (!Array.isArray(coords) || coords.length < 2 || !coords.every(_isGeoJsonPositionMcp)) {
|
|
6208
|
+
errors.push({ path: coordPath, message: "LineString coordinates must be an array of at least 2 positions" });
|
|
6209
|
+
}
|
|
6210
|
+
break;
|
|
6211
|
+
case "MultiLineString":
|
|
6212
|
+
if (!Array.isArray(coords) || !coords.every((line) => Array.isArray(line) && line.length >= 2 && line.every(_isGeoJsonPositionMcp))) {
|
|
6213
|
+
errors.push({ path: coordPath, message: "MultiLineString coordinates must be an array of LineString coordinate arrays" });
|
|
6214
|
+
}
|
|
6215
|
+
break;
|
|
6216
|
+
case "Polygon":
|
|
6217
|
+
_validateGeoJsonPolygonCoordsMcp(coords, coordPath, errors, warnings);
|
|
6218
|
+
break;
|
|
6219
|
+
case "MultiPolygon":
|
|
6220
|
+
if (!Array.isArray(coords)) {
|
|
6221
|
+
errors.push({ path: coordPath, message: "MultiPolygon coordinates must be an array of Polygon coordinate arrays" });
|
|
6222
|
+
} else {
|
|
6223
|
+
coords.forEach((poly, i) => _validateGeoJsonPolygonCoordsMcp(poly, `${coordPath}[${i}]`, errors, warnings));
|
|
6224
|
+
}
|
|
6225
|
+
break;
|
|
6226
|
+
}
|
|
6227
|
+
}
|
|
6228
|
+
function _validateGeoJsonBboxMcp(bbox, path, errors) {
|
|
6229
|
+
if (bbox === void 0) return;
|
|
6230
|
+
if (!Array.isArray(bbox) || bbox.length % 2 !== 0 || bbox.length < 4 || !bbox.every(_isGeoJsonNumberMcp)) {
|
|
6231
|
+
errors.push({ path: `${path}.bbox`, message: "bbox must be an array of 2*n numbers (min/max per axis)" });
|
|
6232
|
+
}
|
|
6233
|
+
}
|
|
6234
|
+
function _validateGeoJsonFeatureMcp(feature, path, errors, warnings) {
|
|
6235
|
+
if (typeof feature !== "object" || feature === null || Array.isArray(feature)) {
|
|
6236
|
+
errors.push({ path, message: "Feature must be an object" });
|
|
6237
|
+
return;
|
|
6238
|
+
}
|
|
6239
|
+
if (feature.type !== "Feature") {
|
|
6240
|
+
errors.push({ path: `${path}.type`, message: '"type" must be "Feature"' });
|
|
6241
|
+
}
|
|
6242
|
+
if (!("properties" in feature) || typeof feature.properties !== "object") {
|
|
6243
|
+
errors.push({ path: `${path}.properties`, message: '"properties" member is required and must be an object or null' });
|
|
6244
|
+
}
|
|
6245
|
+
if (!("geometry" in feature)) {
|
|
6246
|
+
errors.push({ path: `${path}.geometry`, message: '"geometry" member is required (an object or null)' });
|
|
6247
|
+
} else {
|
|
6248
|
+
_validateGeoJsonGeometryMcp(feature.geometry, `${path}.geometry`, errors, warnings);
|
|
6249
|
+
}
|
|
6250
|
+
_validateGeoJsonBboxMcp(feature.bbox, path, errors);
|
|
6251
|
+
}
|
|
6252
|
+
function _validateGeoJsonMcp(input) {
|
|
6253
|
+
let doc;
|
|
6254
|
+
try {
|
|
6255
|
+
doc = JSON.parse(input);
|
|
6256
|
+
} catch (e) {
|
|
6257
|
+
return { valid: false, type: null, errors: [{ path: "$", message: `invalid JSON: ${e.message}` }], warnings: [] };
|
|
6258
|
+
}
|
|
6259
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
6260
|
+
return { valid: false, type: null, errors: [{ path: "$", message: "document must be a JSON object" }], warnings: [] };
|
|
6261
|
+
}
|
|
6262
|
+
const type = doc.type;
|
|
6263
|
+
if (typeof type !== "string") {
|
|
6264
|
+
return { valid: false, type: null, errors: [{ path: "$.type", message: '"type" member is required' }], warnings: [] };
|
|
6265
|
+
}
|
|
6266
|
+
const errors = [];
|
|
6267
|
+
const warnings = [];
|
|
6268
|
+
_validateGeoJsonBboxMcp(doc.bbox, "$", errors);
|
|
6269
|
+
if (type === "FeatureCollection") {
|
|
6270
|
+
if (!Array.isArray(doc.features)) {
|
|
6271
|
+
errors.push({ path: "$.features", message: 'FeatureCollection must have a "features" array' });
|
|
6272
|
+
} else {
|
|
6273
|
+
doc.features.forEach((feature, i) => _validateGeoJsonFeatureMcp(feature, `$.features[${i}]`, errors, warnings));
|
|
6274
|
+
}
|
|
6275
|
+
} else if (type === "Feature") {
|
|
6276
|
+
_validateGeoJsonFeatureMcp(doc, "$", errors, warnings);
|
|
6277
|
+
} else if (_GEOJSON_GEOMETRY_TYPES_MCP.has(type)) {
|
|
6278
|
+
_validateGeoJsonGeometryMcp(doc, "$", errors, warnings);
|
|
6279
|
+
} else {
|
|
6280
|
+
errors.push({ path: "$.type", message: '"type" must be one of Feature, FeatureCollection, or a geometry type' });
|
|
6281
|
+
}
|
|
6282
|
+
return { valid: errors.length === 0, type, errors, warnings };
|
|
6283
|
+
}
|
|
6284
|
+
server.tool(
|
|
6285
|
+
"validate_geojson",
|
|
6286
|
+
`Validate a GeoJSON document's structure against RFC 7946: the correct geometry type and coordinates shape for each of the seven geometry types, linear ring closure for Polygon/MultiPolygon, a Feature's required properties/geometry members, and a FeatureCollection's required features array. Also returns non-failing warnings when a Polygon ring winds the "wrong" way under RFC 7946's right-hand rule recommendation. Pure structural validation; no map is rendered and no tile server is contacted.`,
|
|
6287
|
+
{
|
|
6288
|
+
geojson: z.string().describe("The GeoJSON document, as a JSON string")
|
|
6289
|
+
},
|
|
6290
|
+
async ({ geojson }) => {
|
|
6291
|
+
const result = _validateGeoJsonMcp(geojson);
|
|
6292
|
+
return text(JSON.stringify(result, null, 2));
|
|
6293
|
+
}
|
|
6294
|
+
);
|
|
6006
6295
|
function _videoReadU32BE(bytes, offset) {
|
|
6007
6296
|
return (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
|
|
6008
6297
|
}
|
|
@@ -15407,5 +15696,115 @@ server.tool(
|
|
|
15407
15696
|
return text(JSON.stringify(result, null, 2));
|
|
15408
15697
|
}
|
|
15409
15698
|
);
|
|
15699
|
+
function _calculatePositionSizeMcp(input) {
|
|
15700
|
+
const { accountSize, riskPercent, entryPrice, stopLossPrice, targetPrice } = input;
|
|
15701
|
+
if (!(accountSize > 0)) return { error: "accountSize must be greater than 0" };
|
|
15702
|
+
if (!(riskPercent > 0) || riskPercent > 100) return { error: "riskPercent must be greater than 0 and at most 100" };
|
|
15703
|
+
if (!(entryPrice > 0)) return { error: "entryPrice must be greater than 0" };
|
|
15704
|
+
if (!(stopLossPrice > 0)) return { error: "stopLossPrice must be greater than 0" };
|
|
15705
|
+
if (entryPrice === stopLossPrice) return { error: "entryPrice and stopLossPrice must differ" };
|
|
15706
|
+
if (targetPrice !== void 0 && !(targetPrice > 0)) return { error: "targetPrice must be greater than 0" };
|
|
15707
|
+
const direction = stopLossPrice < entryPrice ? "long" : "short";
|
|
15708
|
+
const riskAmount = accountSize * (riskPercent / 100);
|
|
15709
|
+
const perUnitRisk = Math.abs(entryPrice - stopLossPrice);
|
|
15710
|
+
const positionSizeUnits = Math.floor(riskAmount / perUnitRisk);
|
|
15711
|
+
const positionValue = positionSizeUnits * entryPrice;
|
|
15712
|
+
const dollarRisk = positionSizeUnits * perUnitRisk;
|
|
15713
|
+
let riskRewardRatio = null;
|
|
15714
|
+
let targetOnCorrectSide = null;
|
|
15715
|
+
if (targetPrice !== void 0) {
|
|
15716
|
+
const reward = Math.abs(targetPrice - entryPrice);
|
|
15717
|
+
riskRewardRatio = Math.round(reward / perUnitRisk * 100) / 100;
|
|
15718
|
+
targetOnCorrectSide = direction === "long" ? targetPrice > entryPrice : targetPrice < entryPrice;
|
|
15719
|
+
}
|
|
15720
|
+
return {
|
|
15721
|
+
direction,
|
|
15722
|
+
riskAmount: Math.round(riskAmount * 100) / 100,
|
|
15723
|
+
perUnitRisk: Math.round(perUnitRisk * 1e4) / 1e4,
|
|
15724
|
+
positionSizeUnits,
|
|
15725
|
+
positionValue: Math.round(positionValue * 100) / 100,
|
|
15726
|
+
dollarRisk: Math.round(dollarRisk * 100) / 100,
|
|
15727
|
+
riskRewardRatio,
|
|
15728
|
+
targetOnCorrectSide
|
|
15729
|
+
};
|
|
15730
|
+
}
|
|
15731
|
+
server.tool(
|
|
15732
|
+
"calculate_position_size",
|
|
15733
|
+
"Compute a trade's position size from account size, risk percent per trade, entry price, and stop-loss price: the dollar risk divided by the per-unit stop distance, floored to a whole share/unit count. Also infers the trade direction (long/short) and, given an optional target price, the risk/reward ratio and whether the target sits on the correct side of entry.",
|
|
15734
|
+
{
|
|
15735
|
+
accountSize: z.number().describe("Total account size"),
|
|
15736
|
+
riskPercent: z.number().describe("Percent of the account willing to risk on this trade (0-100)"),
|
|
15737
|
+
entryPrice: z.number().describe("The planned entry price"),
|
|
15738
|
+
stopLossPrice: z.number().describe("The stop-loss price; must differ from entryPrice"),
|
|
15739
|
+
targetPrice: z.number().optional().describe("Optional take-profit price, used to compute the risk/reward ratio")
|
|
15740
|
+
},
|
|
15741
|
+
async ({ accountSize, riskPercent, entryPrice, stopLossPrice, targetPrice }) => {
|
|
15742
|
+
const result = _calculatePositionSizeMcp({ accountSize, riskPercent, entryPrice, stopLossPrice, targetPrice });
|
|
15743
|
+
if ("error" in result) return errText(result);
|
|
15744
|
+
return text(JSON.stringify(result, null, 2));
|
|
15745
|
+
}
|
|
15746
|
+
);
|
|
15747
|
+
var _WEASEL_PHRASES_MCP = [
|
|
15748
|
+
"many experts believe",
|
|
15749
|
+
"some experts say",
|
|
15750
|
+
"experts agree",
|
|
15751
|
+
"critics argue",
|
|
15752
|
+
"studies show",
|
|
15753
|
+
"studies have shown",
|
|
15754
|
+
"research shows",
|
|
15755
|
+
"research suggests",
|
|
15756
|
+
"it is widely believed",
|
|
15757
|
+
"it is widely known",
|
|
15758
|
+
"it is widely regarded",
|
|
15759
|
+
"it is generally accepted",
|
|
15760
|
+
"it is often said",
|
|
15761
|
+
"it has been suggested",
|
|
15762
|
+
"it is said that",
|
|
15763
|
+
"some say",
|
|
15764
|
+
"some people believe",
|
|
15765
|
+
"some people think",
|
|
15766
|
+
"many believe",
|
|
15767
|
+
"many people think",
|
|
15768
|
+
"many claim",
|
|
15769
|
+
"observers note",
|
|
15770
|
+
"sources say",
|
|
15771
|
+
"according to some",
|
|
15772
|
+
"widely regarded as",
|
|
15773
|
+
"many consider",
|
|
15774
|
+
"some scientists believe",
|
|
15775
|
+
"some argue",
|
|
15776
|
+
"reportedly",
|
|
15777
|
+
"allegedly"
|
|
15778
|
+
];
|
|
15779
|
+
function _weaselRegexMcp(phrase) {
|
|
15780
|
+
const tokens = phrase.split(/\s+/).map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
15781
|
+
return new RegExp("\\b" + tokens.join("\\s+") + "\\b", "gi");
|
|
15782
|
+
}
|
|
15783
|
+
function _detectWeaselWordsMcp(input) {
|
|
15784
|
+
const totalWords = (input.match(/[A-Za-z0-9']+/g) ?? []).length;
|
|
15785
|
+
const matches = [];
|
|
15786
|
+
for (const phrase of _WEASEL_PHRASES_MCP) {
|
|
15787
|
+
const re = _weaselRegexMcp(phrase);
|
|
15788
|
+
const indices = [];
|
|
15789
|
+
let m;
|
|
15790
|
+
while ((m = re.exec(input)) !== null) {
|
|
15791
|
+
indices.push(m.index);
|
|
15792
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
15793
|
+
}
|
|
15794
|
+
if (indices.length > 0) matches.push({ phrase, count: indices.length, indices });
|
|
15795
|
+
}
|
|
15796
|
+
matches.sort((a, b) => b.count - a.count || a.phrase.localeCompare(b.phrase));
|
|
15797
|
+
const totalMatches = matches.reduce((s, x) => s + x.count, 0);
|
|
15798
|
+
const density = totalWords > 0 ? Math.round(totalMatches / totalWords * 1e3) / 10 : 0;
|
|
15799
|
+
return { totalWords, totalMatches, density, matches };
|
|
15800
|
+
}
|
|
15801
|
+
server.tool(
|
|
15802
|
+
"detect_weasel_words",
|
|
15803
|
+
`Scan text for weasel words: vague hedging phrases that imply evidence or consensus without naming a checkable source ("studies show", "many experts believe", "it is widely known"). Dictionary-based heuristic over a curated phrase list, distinct from detect_filler_words' "hedge" category, which covers personal uncertainty ("maybe", "I think") rather than unattributed third-party authority.`,
|
|
15804
|
+
{ text: z.string().describe("The text to scan for weasel words") },
|
|
15805
|
+
async ({ text: input }) => {
|
|
15806
|
+
return text(JSON.stringify(_detectWeaselWordsMcp(input), null, 2));
|
|
15807
|
+
}
|
|
15808
|
+
);
|
|
15410
15809
|
var transport = new StdioServerTransport();
|
|
15411
15810
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@utilix-tech/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server exposing
|
|
3
|
+
"version": "0.98.0",
|
|
4
|
+
"description": "MCP server exposing 287 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",
|
|
7
7
|
"homepage": "https://utilix.tech/mcp",
|