myclaw-toolkit 1.0.8 → 1.0.10
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/dist/index.js +277 -61
- package/dist/tools.test.js +239 -0
- package/package.json +15 -5
package/dist/index.js
CHANGED
|
@@ -5,16 +5,28 @@
|
|
|
5
5
|
* Exposes 23 tools to AI assistants (Claude, ChatGPT, Cursor, etc.)
|
|
6
6
|
* via the Model Context Protocol (MCP).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Privacy-first design: all local-computable operations run entirely
|
|
9
|
+
* on-device. Only tools that genuinely need external data (search,
|
|
10
|
+
* exchange rates, crypto prices, etc.) call the remote API.
|
|
10
11
|
*/
|
|
11
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
14
|
import { z } from "zod";
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import QRCode from "qrcode";
|
|
18
|
+
import { marked } from "marked";
|
|
19
|
+
// ── Version (read from package.json, never hardcoded) ─────────────
|
|
20
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
21
|
+
const VERSION = pkg.version;
|
|
14
22
|
// ── Config ──────────────────────────────────────────────────────────
|
|
15
23
|
const API_BASE = process.env.MYCLAW_API || "http://47.103.7.241";
|
|
16
|
-
const USER_AGENT =
|
|
24
|
+
const USER_AGENT = `myclaw-toolkit-mcp/${VERSION}`;
|
|
17
25
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
26
|
+
/**
|
|
27
|
+
* Safe API call with timeout, proper error reading, and abort support.
|
|
28
|
+
* Only used for tools that genuinely need external data.
|
|
29
|
+
*/
|
|
18
30
|
async function apiCall(path) {
|
|
19
31
|
const controller = new AbortController();
|
|
20
32
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
@@ -23,10 +35,18 @@ async function apiCall(path) {
|
|
|
23
35
|
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
|
|
24
36
|
signal: controller.signal,
|
|
25
37
|
});
|
|
38
|
+
const body = await res.text();
|
|
26
39
|
if (!res.ok) {
|
|
27
|
-
|
|
40
|
+
// Parse error body if JSON, otherwise return raw text
|
|
41
|
+
try {
|
|
42
|
+
const errJson = JSON.parse(body);
|
|
43
|
+
return JSON.stringify({ error: `API ${res.status}`, detail: errJson, path });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return JSON.stringify({ error: `API ${res.status}`, detail: body.slice(0, 500), path });
|
|
47
|
+
}
|
|
28
48
|
}
|
|
29
|
-
return
|
|
49
|
+
return body;
|
|
30
50
|
}
|
|
31
51
|
catch (err) {
|
|
32
52
|
if (err.name === "AbortError") {
|
|
@@ -38,73 +58,236 @@ async function apiCall(path) {
|
|
|
38
58
|
clearTimeout(timer);
|
|
39
59
|
}
|
|
40
60
|
}
|
|
61
|
+
// ── Local utility functions ───────────────────────────────────────
|
|
62
|
+
function hexToRgb(hex) {
|
|
63
|
+
const h = hex.replace("#", "");
|
|
64
|
+
return [
|
|
65
|
+
parseInt(h.slice(0, 2), 16),
|
|
66
|
+
parseInt(h.slice(2, 4), 16),
|
|
67
|
+
parseInt(h.slice(4, 6), 16),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
function rgbToHsl(r, g, b) {
|
|
71
|
+
r /= 255;
|
|
72
|
+
g /= 255;
|
|
73
|
+
b /= 255;
|
|
74
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
75
|
+
let h = 0, s = 0, l = (max + min) / 2;
|
|
76
|
+
if (max !== min) {
|
|
77
|
+
const d = max - min;
|
|
78
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
79
|
+
switch (max) {
|
|
80
|
+
case r:
|
|
81
|
+
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
82
|
+
break;
|
|
83
|
+
case g:
|
|
84
|
+
h = ((b - r) / d + 2) / 6;
|
|
85
|
+
break;
|
|
86
|
+
case b:
|
|
87
|
+
h = ((r - g) / d + 4) / 6;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
|
|
92
|
+
}
|
|
93
|
+
function hslToRgb(h, s, l) {
|
|
94
|
+
s /= 100;
|
|
95
|
+
l /= 100;
|
|
96
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
97
|
+
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
|
98
|
+
const m = l - c / 2;
|
|
99
|
+
let r = 0, g = 0, b = 0;
|
|
100
|
+
if (h < 60) {
|
|
101
|
+
r = c;
|
|
102
|
+
g = x;
|
|
103
|
+
}
|
|
104
|
+
else if (h < 120) {
|
|
105
|
+
r = x;
|
|
106
|
+
g = c;
|
|
107
|
+
}
|
|
108
|
+
else if (h < 180) {
|
|
109
|
+
g = c;
|
|
110
|
+
b = x;
|
|
111
|
+
}
|
|
112
|
+
else if (h < 240) {
|
|
113
|
+
g = x;
|
|
114
|
+
b = c;
|
|
115
|
+
}
|
|
116
|
+
else if (h < 300) {
|
|
117
|
+
r = x;
|
|
118
|
+
b = c;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
r = c;
|
|
122
|
+
b = x;
|
|
123
|
+
}
|
|
124
|
+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
|
125
|
+
}
|
|
41
126
|
// ── Server ──────────────────────────────────────────────────────────
|
|
42
127
|
const server = new McpServer({
|
|
43
128
|
name: "myclaw-toolkit",
|
|
44
|
-
version:
|
|
129
|
+
version: VERSION,
|
|
45
130
|
});
|
|
46
131
|
// ═══════════════════════════════════════════════════════════════════
|
|
47
|
-
// CATEGORY 1: UTILITY TOOLS
|
|
132
|
+
// CATEGORY 1: UTILITY TOOLS — All local, zero network
|
|
48
133
|
// ═══════════════════════════════════════════════════════════════════
|
|
49
|
-
server.tool("timestamp", "Get current Unix timestamp and ISO 8601 datetime", {}, async () => {
|
|
50
|
-
const
|
|
51
|
-
return {
|
|
134
|
+
server.tool("timestamp", "Get current Unix timestamp and ISO 8601 datetime (runs locally, no network)", {}, async () => {
|
|
135
|
+
const now = new Date();
|
|
136
|
+
return {
|
|
137
|
+
content: [{
|
|
138
|
+
type: "text",
|
|
139
|
+
text: JSON.stringify({
|
|
140
|
+
unix_ms: now.getTime(),
|
|
141
|
+
unix_sec: Math.floor(now.getTime() / 1000),
|
|
142
|
+
iso8601: now.toISOString(),
|
|
143
|
+
utc: now.toUTCString(),
|
|
144
|
+
local: now.toString(),
|
|
145
|
+
}),
|
|
146
|
+
}],
|
|
147
|
+
};
|
|
52
148
|
});
|
|
53
|
-
server.tool("uuid", "Generate UUID v4 identifiers", {
|
|
149
|
+
server.tool("uuid", "Generate UUID v4 identifiers (runs locally, no network)", {
|
|
54
150
|
count: z.number().min(1).max(100).default(1).describe("Number of UUIDs to generate"),
|
|
55
151
|
}, async ({ count }) => {
|
|
56
|
-
const
|
|
57
|
-
return { content: [{ type: "text", text:
|
|
152
|
+
const uuids = Array.from({ length: count }, () => crypto.randomUUID());
|
|
153
|
+
return { content: [{ type: "text", text: JSON.stringify({ uuids }) }] };
|
|
58
154
|
});
|
|
59
|
-
server.tool("base64", "Encode or decode Base64 strings", {
|
|
155
|
+
server.tool("base64", "Encode or decode Base64 strings (runs locally, no data leaves your machine)", {
|
|
60
156
|
action: z.enum(["encode", "decode"]).describe("Operation to perform"),
|
|
61
157
|
text: z.string().describe("Text to encode or Base64 to decode"),
|
|
62
158
|
}, async ({ action, text }) => {
|
|
63
|
-
|
|
64
|
-
|
|
159
|
+
try {
|
|
160
|
+
if (action === "encode") {
|
|
161
|
+
const encoded = Buffer.from(text, "utf-8").toString("base64");
|
|
162
|
+
return { content: [{ type: "text", text: JSON.stringify({ action, input: text, output: encoded }) }] };
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const decoded = Buffer.from(text, "base64").toString("utf-8");
|
|
166
|
+
return { content: [{ type: "text", text: JSON.stringify({ action, input: text, output: decoded }) }] };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: err.message, action, input: text }) }] };
|
|
171
|
+
}
|
|
65
172
|
});
|
|
66
|
-
server.tool("hash", "Generate cryptographic hashes
|
|
173
|
+
server.tool("hash", "Generate cryptographic hashes — MD5, SHA1, SHA256, SHA512 (runs locally, no network)", {
|
|
67
174
|
algorithm: z.enum(["md5", "sha1", "sha256", "sha512"]).default("sha256").describe("Hash algorithm"),
|
|
68
175
|
text: z.string().describe("Text to hash"),
|
|
69
176
|
}, async ({ algorithm, text }) => {
|
|
70
|
-
const
|
|
71
|
-
return { content: [{ type: "text", text:
|
|
177
|
+
const hash = crypto.createHash(algorithm).update(text, "utf-8").digest("hex");
|
|
178
|
+
return { content: [{ type: "text", text: JSON.stringify({ algorithm, input: text, hash }) }] };
|
|
72
179
|
});
|
|
73
|
-
server.tool("qrcode", "Generate QR codes from text or URLs", {
|
|
180
|
+
server.tool("qrcode", "Generate QR codes from text or URLs (runs locally, no network)", {
|
|
74
181
|
text: z.string().describe("Text or URL to encode in QR code"),
|
|
75
182
|
}, async ({ text }) => {
|
|
76
|
-
const
|
|
77
|
-
return { content: [{ type: "text", text:
|
|
183
|
+
const dataUrl = await QRCode.toDataURL(text, { width: 400, margin: 2 });
|
|
184
|
+
return { content: [{ type: "text", text: dataUrl }] };
|
|
78
185
|
});
|
|
79
|
-
server.tool("color_tools", "Convert colors between hex, RGB, and HSL formats", {
|
|
80
|
-
color: z.string().describe("Color value (hex like #ff0000, rgb like 255,0,0, or hsl like 0,100,50)"),
|
|
186
|
+
server.tool("color_tools", "Convert colors between hex, RGB, and HSL formats (runs locally, no network)", {
|
|
187
|
+
color: z.string().describe("Color value (hex like '#ff0000', rgb like '255,0,0', or hsl like '0,100,50')"),
|
|
81
188
|
}, async ({ color }) => {
|
|
82
|
-
|
|
83
|
-
|
|
189
|
+
try {
|
|
190
|
+
const c = color.trim();
|
|
191
|
+
// Detect format
|
|
192
|
+
if (c.startsWith("#") || /^[0-9a-fA-F]{6}$/.test(c)) {
|
|
193
|
+
const rgb = hexToRgb(c);
|
|
194
|
+
const hsl = rgbToHsl(...rgb);
|
|
195
|
+
return { content: [{ type: "text", text: JSON.stringify({ hex: `#${c.replace("#", "")}`, rgb, hsl }) }] };
|
|
196
|
+
}
|
|
197
|
+
if (c.toLowerCase().includes("hsl")) {
|
|
198
|
+
const match = c.match(/[\d.]+/g);
|
|
199
|
+
if (match && match.length >= 3) {
|
|
200
|
+
const [h, s, l] = [parseFloat(match[0]), parseFloat(match[1]), parseFloat(match[2])];
|
|
201
|
+
const rgb = hslToRgb(h, s, l);
|
|
202
|
+
const hex = "#" + rgb.map(v => v.toString(16).padStart(2, "0")).join("");
|
|
203
|
+
return { content: [{ type: "text", text: JSON.stringify({ hex, rgb, hsl: [h, s, l] }) }] };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (c.includes(",")) {
|
|
207
|
+
const parts = c.split(",").map(Number);
|
|
208
|
+
if (parts.length >= 3 && !isNaN(parts[0])) {
|
|
209
|
+
const [r, g, b] = parts;
|
|
210
|
+
const hsl = rgbToHsl(r, g, b);
|
|
211
|
+
const hex = "#" + [r, g, b].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("");
|
|
212
|
+
return { content: [{ type: "text", text: JSON.stringify({ hex, rgb: [r, g, b], hsl }) }] };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Unrecognized color format. Use hex (#ff0000), rgb (255,0,0), or hsl (0,100,50)" }) }] };
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
|
|
219
|
+
}
|
|
84
220
|
});
|
|
85
|
-
server.tool("json_formatter", "Format, validate, or minify JSON strings", {
|
|
221
|
+
server.tool("json_formatter", "Format, validate, or minify JSON strings (runs locally, no data leaves your machine)", {
|
|
86
222
|
action: z.enum(["format", "validate", "minify"]).default("format").describe("Operation"),
|
|
87
223
|
json: z.string().describe("JSON string to process"),
|
|
88
224
|
}, async ({ action, json }) => {
|
|
89
|
-
|
|
90
|
-
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(json);
|
|
227
|
+
if (action === "validate") {
|
|
228
|
+
return { content: [{ type: "text", text: JSON.stringify({ valid: true, depth: getDepth(parsed) }) }] };
|
|
229
|
+
}
|
|
230
|
+
const output = action === "minify" ? JSON.stringify(parsed) : JSON.stringify(parsed, null, 2);
|
|
231
|
+
return { content: [{ type: "text", text: output }] };
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
if (action === "validate") {
|
|
235
|
+
return { content: [{ type: "text", text: JSON.stringify({ valid: false, error: err.message }) }] };
|
|
236
|
+
}
|
|
237
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Invalid JSON: ${err.message}` }) }] };
|
|
238
|
+
}
|
|
91
239
|
});
|
|
92
|
-
|
|
240
|
+
function getDepth(obj, depth = 0) {
|
|
241
|
+
if (typeof obj !== "object" || obj === null || obj === undefined)
|
|
242
|
+
return depth;
|
|
243
|
+
let max = depth;
|
|
244
|
+
for (const key of Object.keys(obj)) {
|
|
245
|
+
const d = getDepth(obj[key], depth + 1);
|
|
246
|
+
if (d > max)
|
|
247
|
+
max = d;
|
|
248
|
+
}
|
|
249
|
+
return max;
|
|
250
|
+
}
|
|
251
|
+
server.tool("url_tools", "Encode or decode URL strings (runs locally, no network)", {
|
|
93
252
|
action: z.enum(["encode", "decode"]).describe("Operation"),
|
|
94
253
|
text: z.string().describe("Text to encode or URL to decode"),
|
|
95
254
|
}, async ({ action, text }) => {
|
|
96
|
-
|
|
97
|
-
|
|
255
|
+
try {
|
|
256
|
+
const output = action === "encode" ? encodeURIComponent(text) : decodeURIComponent(text);
|
|
257
|
+
return { content: [{ type: "text", text: JSON.stringify({ action, input: text, output }) }] };
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: err.message, action, input: text }) }] };
|
|
261
|
+
}
|
|
98
262
|
});
|
|
99
|
-
server.tool("text_tools", "Text processing utilities
|
|
263
|
+
server.tool("text_tools", "Text processing utilities — count, reverse, case conversion (runs locally, no network)", {
|
|
100
264
|
action: z.enum(["count", "reverse", "upper", "lower", "title"]).describe("Operation"),
|
|
101
265
|
text: z.string().describe("Text to process"),
|
|
102
266
|
}, async ({ action, text }) => {
|
|
103
|
-
|
|
104
|
-
|
|
267
|
+
let output;
|
|
268
|
+
switch (action) {
|
|
269
|
+
case "count":
|
|
270
|
+
output = { chars: text.length, words: text.trim() ? text.trim().split(/\s+/).length : 0, lines: text.split("\n").length };
|
|
271
|
+
break;
|
|
272
|
+
case "reverse":
|
|
273
|
+
output = text.split("").reverse().join("");
|
|
274
|
+
break;
|
|
275
|
+
case "upper":
|
|
276
|
+
output = text.toUpperCase();
|
|
277
|
+
break;
|
|
278
|
+
case "lower":
|
|
279
|
+
output = text.toLowerCase();
|
|
280
|
+
break;
|
|
281
|
+
case "title":
|
|
282
|
+
output = text.replace(/\w\S*/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
|
|
283
|
+
break;
|
|
284
|
+
default:
|
|
285
|
+
output = text;
|
|
286
|
+
}
|
|
287
|
+
return { content: [{ type: "text", text: JSON.stringify({ action, input: text, output }) }] };
|
|
105
288
|
});
|
|
106
289
|
// ═══════════════════════════════════════════════════════════════════
|
|
107
|
-
// CATEGORY 2: DATA TOOLS
|
|
290
|
+
// CATEGORY 2: DATA TOOLS — Mixed local + remote
|
|
108
291
|
// ═══════════════════════════════════════════════════════════════════
|
|
109
292
|
server.tool("exchange_rate", "Get real-time currency exchange rates between any two currencies", {
|
|
110
293
|
from: z.string().length(3).describe("Source currency code (e.g., USD, CNY, EUR)"),
|
|
@@ -126,31 +309,47 @@ server.tool("domain_check", "Check domain name availability and whois informatio
|
|
|
126
309
|
const result = await apiCall(`/domain?name=${encodeURIComponent(domain)}`);
|
|
127
310
|
return { content: [{ type: "text", text: result }] };
|
|
128
311
|
});
|
|
129
|
-
server.tool("bmi_calculator", "Calculate BMI (Body Mass Index) from height and weight", {
|
|
312
|
+
server.tool("bmi_calculator", "Calculate BMI (Body Mass Index) from height and weight (runs locally, no network)", {
|
|
130
313
|
height: z.number().min(50).max(300).describe("Height in centimeters"),
|
|
131
314
|
weight: z.number().min(1).max(500).describe("Weight in kilograms"),
|
|
132
315
|
}, async ({ height, weight }) => {
|
|
133
|
-
const
|
|
134
|
-
|
|
316
|
+
const h = height / 100;
|
|
317
|
+
const bmi = weight / (h * h);
|
|
318
|
+
let category;
|
|
319
|
+
if (bmi < 18.5)
|
|
320
|
+
category = "Underweight";
|
|
321
|
+
else if (bmi < 25)
|
|
322
|
+
category = "Normal weight";
|
|
323
|
+
else if (bmi < 30)
|
|
324
|
+
category = "Overweight";
|
|
325
|
+
else
|
|
326
|
+
category = "Obese";
|
|
327
|
+
return {
|
|
328
|
+
content: [{
|
|
329
|
+
type: "text",
|
|
330
|
+
text: JSON.stringify({ height_cm: height, weight_kg: weight, bmi: Math.round(bmi * 100) / 100, category }),
|
|
331
|
+
}],
|
|
332
|
+
};
|
|
135
333
|
});
|
|
136
|
-
server.tool("vcard_generator", "Generate vCard (.vcf) contact files", {
|
|
334
|
+
server.tool("vcard_generator", "Generate vCard (.vcf) contact files (runs locally, no network)", {
|
|
137
335
|
name: z.string().describe("Full name"),
|
|
138
336
|
phone: z.string().optional().describe("Phone number"),
|
|
139
337
|
email: z.string().optional().describe("Email address"),
|
|
140
338
|
org: z.string().optional().describe("Organization/company"),
|
|
141
339
|
}, async ({ name, phone, email, org }) => {
|
|
142
|
-
const
|
|
340
|
+
const lines = ["BEGIN:VCARD", "VERSION:3.0", `FN:${name}`];
|
|
341
|
+
if (org)
|
|
342
|
+
lines.push(`ORG:${org}`);
|
|
143
343
|
if (phone)
|
|
144
|
-
|
|
344
|
+
lines.push(`TEL:${phone}`);
|
|
145
345
|
if (email)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return { content: [{ type: "text", text: result }] };
|
|
346
|
+
lines.push(`EMAIL:${email}`);
|
|
347
|
+
lines.push("END:VCARD");
|
|
348
|
+
const vcard = lines.join("\n");
|
|
349
|
+
return { content: [{ type: "text", text: vcard }] };
|
|
151
350
|
});
|
|
152
351
|
// ═══════════════════════════════════════════════════════════════════
|
|
153
|
-
// CATEGORY 3: SEARCH & CONTENT TOOLS
|
|
352
|
+
// CATEGORY 3: SEARCH & CONTENT TOOLS — Remote API
|
|
154
353
|
// ═══════════════════════════════════════════════════════════════════
|
|
155
354
|
server.tool("web_search", "Search the web using Bing — returns titles, snippets, and URLs", {
|
|
156
355
|
query: z.string().describe("Search query"),
|
|
@@ -187,42 +386,56 @@ server.tool("read_page", "Extract readable content from any web page (like Reada
|
|
|
187
386
|
return { content: [{ type: "text", text: result }] };
|
|
188
387
|
});
|
|
189
388
|
// ═══════════════════════════════════════════════════════════════════
|
|
190
|
-
// CATEGORY 4: PROCESSING TOOLS
|
|
389
|
+
// CATEGORY 4: PROCESSING TOOLS
|
|
191
390
|
// ═══════════════════════════════════════════════════════════════════
|
|
192
|
-
server.tool("markdown_to_html", "Convert Markdown text to HTML", {
|
|
391
|
+
server.tool("markdown_to_html", "Convert Markdown text to HTML (runs locally, no data leaves your machine)", {
|
|
193
392
|
markdown: z.string().describe("Markdown text to convert"),
|
|
194
393
|
}, async ({ markdown }) => {
|
|
195
|
-
const
|
|
196
|
-
return { content: [{ type: "text", text:
|
|
394
|
+
const html = await marked.parse(markdown, { async: true });
|
|
395
|
+
return { content: [{ type: "text", text: html }] };
|
|
197
396
|
});
|
|
198
|
-
server.tool("ai_translate", "Translate text between languages
|
|
397
|
+
server.tool("ai_translate", "Translate text between languages (uses MyMemory translation API)", {
|
|
199
398
|
text: z.string().describe("Text to translate"),
|
|
200
399
|
from: z.string().default("auto").describe("Source language (auto for auto-detect)"),
|
|
201
400
|
to: z.string().default("en").describe("Target language"),
|
|
202
401
|
}, async ({ text, from, to }) => {
|
|
203
402
|
const langPair = from === "auto" ? `autodetect|${to}` : `${from}|${to}`;
|
|
204
403
|
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${encodeURIComponent(langPair)}`;
|
|
404
|
+
const controller = new AbortController();
|
|
405
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
205
406
|
try {
|
|
206
|
-
const res = await fetch(url);
|
|
407
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
207
408
|
const data = await res.json();
|
|
208
409
|
const translated = data?.responseData?.translatedText || `Error: ${data?.responseStatus || "unknown"}`;
|
|
209
410
|
return { content: [{ type: "text", text: JSON.stringify({ from, to, original: text, translated }) }] };
|
|
210
411
|
}
|
|
211
|
-
catch {
|
|
212
|
-
|
|
412
|
+
catch (err) {
|
|
413
|
+
if (err.name === "AbortError") {
|
|
414
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Translation request timed out", from, to, original: text }) }] };
|
|
415
|
+
}
|
|
416
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Translation service unavailable: ${err.message}`, from, to, original: text }) }] };
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
clearTimeout(timer);
|
|
213
420
|
}
|
|
214
421
|
});
|
|
215
|
-
server.tool("quote", "Get inspirational or random quotes", {}, async () => {
|
|
422
|
+
server.tool("quote", "Get inspirational or random quotes (remote API, no user data sent)", {}, async () => {
|
|
216
423
|
const result = await apiCall("/quote");
|
|
217
424
|
return { content: [{ type: "text", text: result }] };
|
|
218
425
|
});
|
|
219
|
-
|
|
426
|
+
/**
|
|
427
|
+
* WiFi QR Code — CRITICAL: runs 100% locally.
|
|
428
|
+
* WiFi passwords NEVER leave the user's machine.
|
|
429
|
+
*/
|
|
430
|
+
server.tool("wifi_qrcode", "Generate WiFi connection QR codes (runs 100% locally — your password NEVER leaves your device)", {
|
|
220
431
|
ssid: z.string().describe("WiFi network name (SSID)"),
|
|
221
432
|
password: z.string().describe("WiFi password"),
|
|
222
433
|
security: z.enum(["WPA", "WEP", "nopass"]).default("WPA").describe("Security type"),
|
|
223
434
|
}, async ({ ssid, password, security }) => {
|
|
224
|
-
|
|
225
|
-
|
|
435
|
+
// WiFi QR code format: WIFI:S:<SSID>;T:<WPA|WEP|>;P:<password>;;
|
|
436
|
+
const wifiString = `WIFI:S:${ssid};T:${security};P:${password};;`;
|
|
437
|
+
const dataUrl = await QRCode.toDataURL(wifiString, { width: 400, margin: 2 });
|
|
438
|
+
return { content: [{ type: "text", text: dataUrl }] };
|
|
226
439
|
});
|
|
227
440
|
server.tool("compare", "Compare two items side-by-side (text, products, anything)", {
|
|
228
441
|
a: z.string().describe("First item to compare"),
|
|
@@ -245,6 +458,7 @@ server.tool("health_check", "Check if the MyClaw API backend is reachable and re
|
|
|
245
458
|
backend: API_BASE,
|
|
246
459
|
status: result.startsWith("{") ? "healthy" : "degraded",
|
|
247
460
|
latency_ms: elapsed,
|
|
461
|
+
version: VERSION,
|
|
248
462
|
}),
|
|
249
463
|
}],
|
|
250
464
|
};
|
|
@@ -255,6 +469,8 @@ server.tool("health_check", "Check if the MyClaw API backend is reachable and re
|
|
|
255
469
|
async function main() {
|
|
256
470
|
const transport = new StdioServerTransport();
|
|
257
471
|
await server.connect(transport);
|
|
258
|
-
console.error(
|
|
472
|
+
console.error(`MyClaw Toolkit v${VERSION} — Privacy-first MCP server running (stdio)`);
|
|
473
|
+
console.error(" Local tools: timestamp, uuid, base64, hash, qrcode, wifi_qrcode, color_tools, json_formatter, url_tools, text_tools, bmi_calculator, vcard_generator, markdown_to_html");
|
|
474
|
+
console.error(" Remote tools: web_search, news_search, product_search, exchange_rate, crypto_price, domain_check, rss_feed, read_page, ai_translate, quote, compare");
|
|
259
475
|
}
|
|
260
476
|
main().catch(console.error);
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Basic tests for local tool implementations.
|
|
4
|
+
* Run: npx vitest run
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
9
|
+
// Utility functions (replicated from index.ts for isolated testing)
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
11
|
+
function hexToRgb(hex) {
|
|
12
|
+
const h = hex.replace("#", "");
|
|
13
|
+
return [
|
|
14
|
+
parseInt(h.slice(0, 2), 16),
|
|
15
|
+
parseInt(h.slice(2, 4), 16),
|
|
16
|
+
parseInt(h.slice(4, 6), 16),
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
function rgbToHsl(r, g, b) {
|
|
20
|
+
r /= 255;
|
|
21
|
+
g /= 255;
|
|
22
|
+
b /= 255;
|
|
23
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
24
|
+
let h = 0, s = 0, l = (max + min) / 2;
|
|
25
|
+
if (max !== min) {
|
|
26
|
+
const d = max - min;
|
|
27
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
28
|
+
switch (max) {
|
|
29
|
+
case r:
|
|
30
|
+
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
31
|
+
break;
|
|
32
|
+
case g:
|
|
33
|
+
h = ((b - r) / d + 2) / 6;
|
|
34
|
+
break;
|
|
35
|
+
case b:
|
|
36
|
+
h = ((r - g) / d + 4) / 6;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
|
|
41
|
+
}
|
|
42
|
+
function hslToRgb(h, s, l) {
|
|
43
|
+
s /= 100;
|
|
44
|
+
l /= 100;
|
|
45
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
46
|
+
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
|
47
|
+
const m = l - c / 2;
|
|
48
|
+
let r = 0, g = 0, b = 0;
|
|
49
|
+
if (h < 60) {
|
|
50
|
+
r = c;
|
|
51
|
+
g = x;
|
|
52
|
+
}
|
|
53
|
+
else if (h < 120) {
|
|
54
|
+
r = x;
|
|
55
|
+
g = c;
|
|
56
|
+
}
|
|
57
|
+
else if (h < 180) {
|
|
58
|
+
g = c;
|
|
59
|
+
b = x;
|
|
60
|
+
}
|
|
61
|
+
else if (h < 240) {
|
|
62
|
+
g = x;
|
|
63
|
+
b = c;
|
|
64
|
+
}
|
|
65
|
+
else if (h < 300) {
|
|
66
|
+
r = x;
|
|
67
|
+
b = c;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
r = c;
|
|
71
|
+
b = x;
|
|
72
|
+
}
|
|
73
|
+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
|
74
|
+
}
|
|
75
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
76
|
+
// Tests
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
78
|
+
describe("timestamp", () => {
|
|
79
|
+
it("generates valid timestamps", () => {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const iso = new Date().toISOString();
|
|
82
|
+
expect(now).toBeGreaterThan(1700000000000); // after 2023
|
|
83
|
+
expect(iso).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe("uuid", () => {
|
|
87
|
+
it("generates valid UUID v4", () => {
|
|
88
|
+
for (let i = 0; i < 10; i++) {
|
|
89
|
+
const uuid = crypto.randomUUID();
|
|
90
|
+
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
it("generates unique UUIDs", () => {
|
|
94
|
+
const uuids = new Set(Array.from({ length: 100 }, () => crypto.randomUUID()));
|
|
95
|
+
expect(uuids.size).toBe(100);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe("base64", () => {
|
|
99
|
+
it("encodes correctly", () => {
|
|
100
|
+
expect(Buffer.from("hello").toString("base64")).toBe("aGVsbG8=");
|
|
101
|
+
expect(Buffer.from("你好").toString("base64")).toBe("5L2g5aW9");
|
|
102
|
+
});
|
|
103
|
+
it("round-trips", () => {
|
|
104
|
+
const original = "Hello, World! 你好世界";
|
|
105
|
+
const encoded = Buffer.from(original, "utf-8").toString("base64");
|
|
106
|
+
const decoded = Buffer.from(encoded, "base64").toString("utf-8");
|
|
107
|
+
expect(decoded).toBe(original);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe("hash", () => {
|
|
111
|
+
it("generates correct SHA256", () => {
|
|
112
|
+
const hash = crypto.createHash("sha256").update("test").digest("hex");
|
|
113
|
+
expect(hash).toBe("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08");
|
|
114
|
+
});
|
|
115
|
+
it("generates correct MD5", () => {
|
|
116
|
+
const hash = crypto.createHash("md5").update("hello").digest("hex");
|
|
117
|
+
expect(hash).toBe("5d41402abc4b2a76b9719d911017c592");
|
|
118
|
+
});
|
|
119
|
+
it("different inputs produce different hashes", () => {
|
|
120
|
+
const h1 = crypto.createHash("sha256").update("a").digest("hex");
|
|
121
|
+
const h2 = crypto.createHash("sha256").update("b").digest("hex");
|
|
122
|
+
expect(h1).not.toBe(h2);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe("color_tools", () => {
|
|
126
|
+
it("converts hex to RGB correctly", () => {
|
|
127
|
+
expect(hexToRgb("#ff0000")).toEqual([255, 0, 0]);
|
|
128
|
+
expect(hexToRgb("#00ff00")).toEqual([0, 255, 0]);
|
|
129
|
+
expect(hexToRgb("#0000ff")).toEqual([0, 0, 255]);
|
|
130
|
+
});
|
|
131
|
+
it("converts hex to HSL correctly", () => {
|
|
132
|
+
const hsl = rgbToHsl(255, 0, 0);
|
|
133
|
+
expect(hsl[0]).toBe(0); // hue
|
|
134
|
+
expect(hsl[1]).toBe(100); // saturation
|
|
135
|
+
expect(hsl[2]).toBe(50); // lightness
|
|
136
|
+
});
|
|
137
|
+
it("round-trips hex → RGB → HSL → RGB → hex", () => {
|
|
138
|
+
const originalHex = "#4a90d9";
|
|
139
|
+
const rgb = hexToRgb(originalHex);
|
|
140
|
+
const hsl = rgbToHsl(...rgb);
|
|
141
|
+
const rgb2 = hslToRgb(...hsl);
|
|
142
|
+
// Allow 1-unit rounding difference
|
|
143
|
+
expect(Math.abs(rgb[0] - rgb2[0])).toBeLessThanOrEqual(1);
|
|
144
|
+
expect(Math.abs(rgb[1] - rgb2[1])).toBeLessThanOrEqual(1);
|
|
145
|
+
expect(Math.abs(rgb[2] - rgb2[2])).toBeLessThanOrEqual(1);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe("bmi_calculator", () => {
|
|
149
|
+
it("calculates BMI correctly", () => {
|
|
150
|
+
const height = 175, weight = 70;
|
|
151
|
+
const h = height / 100;
|
|
152
|
+
const bmi = weight / (h * h);
|
|
153
|
+
expect(Math.round(bmi * 100) / 100).toBe(22.86);
|
|
154
|
+
});
|
|
155
|
+
it("correctly categorizes BMI", () => {
|
|
156
|
+
const categorize = (bmi) => {
|
|
157
|
+
if (bmi < 18.5)
|
|
158
|
+
return "Underweight";
|
|
159
|
+
if (bmi < 25)
|
|
160
|
+
return "Normal weight";
|
|
161
|
+
if (bmi < 30)
|
|
162
|
+
return "Overweight";
|
|
163
|
+
return "Obese";
|
|
164
|
+
};
|
|
165
|
+
expect(categorize(17)).toBe("Underweight");
|
|
166
|
+
expect(categorize(22)).toBe("Normal weight");
|
|
167
|
+
expect(categorize(27)).toBe("Overweight");
|
|
168
|
+
expect(categorize(32)).toBe("Obese");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
describe("json_formatter", () => {
|
|
172
|
+
it("formats JSON", () => {
|
|
173
|
+
const obj = { a: 1, b: [2, 3] };
|
|
174
|
+
expect(JSON.parse(JSON.stringify(obj))).toEqual(obj);
|
|
175
|
+
});
|
|
176
|
+
it("detects invalid JSON", () => {
|
|
177
|
+
expect(() => JSON.parse("{bad json")).toThrow();
|
|
178
|
+
});
|
|
179
|
+
it("minifies JSON", () => {
|
|
180
|
+
const minified = JSON.stringify({ a: 1, b: 2 });
|
|
181
|
+
expect(minified).toBe('{"a":1,"b":2}');
|
|
182
|
+
expect(minified).not.toContain("\n");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe("url_tools", () => {
|
|
186
|
+
it("encodes URL components", () => {
|
|
187
|
+
expect(encodeURIComponent("hello world")).toBe("hello%20world");
|
|
188
|
+
expect(encodeURIComponent("你好")).toBe("%E4%BD%A0%E5%A5%BD");
|
|
189
|
+
});
|
|
190
|
+
it("decodes URL components", () => {
|
|
191
|
+
expect(decodeURIComponent("hello%20world")).toBe("hello world");
|
|
192
|
+
expect(decodeURIComponent("%E4%BD%A0%E5%A5%BD")).toBe("你好");
|
|
193
|
+
});
|
|
194
|
+
it("round-trips", () => {
|
|
195
|
+
const original = "https://example.com?q=你好世界";
|
|
196
|
+
expect(decodeURIComponent(encodeURIComponent(original))).toBe(original);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
describe("text_tools", () => {
|
|
200
|
+
it("counts characters, words, lines", () => {
|
|
201
|
+
const text = "Hello world\nHow are you?";
|
|
202
|
+
expect(text.length).toBe(24);
|
|
203
|
+
expect(text.trim().split(/\s+/).length).toBe(5);
|
|
204
|
+
expect(text.split("\n").length).toBe(2);
|
|
205
|
+
});
|
|
206
|
+
it("reverses text", () => {
|
|
207
|
+
expect("hello".split("").reverse().join("")).toBe("olleh");
|
|
208
|
+
});
|
|
209
|
+
it("converts case", () => {
|
|
210
|
+
expect("Hello".toUpperCase()).toBe("HELLO");
|
|
211
|
+
expect("Hello".toLowerCase()).toBe("hello");
|
|
212
|
+
expect("hello world".replace(/\w\S*/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())).toBe("Hello World");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe("vcard_generator", () => {
|
|
216
|
+
it("generates valid vCard", () => {
|
|
217
|
+
const vcard = [
|
|
218
|
+
"BEGIN:VCARD",
|
|
219
|
+
"VERSION:3.0",
|
|
220
|
+
"FN:John Doe",
|
|
221
|
+
"ORG:ACME Inc",
|
|
222
|
+
"TEL:+1234567890",
|
|
223
|
+
"EMAIL:john@example.com",
|
|
224
|
+
"END:VCARD",
|
|
225
|
+
].join("\n");
|
|
226
|
+
expect(vcard).toContain("BEGIN:VCARD");
|
|
227
|
+
expect(vcard).toContain("FN:John Doe");
|
|
228
|
+
expect(vcard).toContain("END:VCARD");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
describe("wifi_qrcode format", () => {
|
|
232
|
+
it("generates correct WiFi string format", () => {
|
|
233
|
+
const ssid = "MyNetwork";
|
|
234
|
+
const password = "secret123";
|
|
235
|
+
const security = "WPA";
|
|
236
|
+
const wifiString = `WIFI:S:${ssid};T:${security};P:${password};;`;
|
|
237
|
+
expect(wifiString).toBe("WIFI:S:MyNetwork;T:WPA;P:secret123;;");
|
|
238
|
+
});
|
|
239
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myclaw-toolkit",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "23-in-1 developer utility toolkit as an MCP server
|
|
3
|
+
"version": "1.0.10",
|
|
4
|
+
"description": "23-in-1 developer utility toolkit as an MCP server — search, exchange rates, crypto, QR codes, translation, and more",
|
|
5
5
|
"mcpName": "io.github.Dusheh/myclaw-toolkit",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"start": "node dist/index.js",
|
|
17
|
-
"dev": "tsx src/index.ts"
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"lint": "eslint src/",
|
|
20
|
+
"test:watch": "vitest"
|
|
18
21
|
},
|
|
19
22
|
"keywords": [
|
|
20
23
|
"mcp",
|
|
@@ -53,11 +56,18 @@
|
|
|
53
56
|
},
|
|
54
57
|
"dependencies": {
|
|
55
58
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
59
|
+
"marked": "^18.0.5",
|
|
60
|
+
"qrcode": "^1.5.4",
|
|
56
61
|
"zod": "^3.23.0"
|
|
57
62
|
},
|
|
58
63
|
"devDependencies": {
|
|
59
64
|
"@types/node": "^22.19.21",
|
|
65
|
+
"@types/qrcode": "^1.5.6",
|
|
66
|
+
"@typescript-eslint/eslint-plugin": "^8.62.0",
|
|
67
|
+
"@typescript-eslint/parser": "^8.62.0",
|
|
68
|
+
"eslint": "^10.5.0",
|
|
60
69
|
"tsx": "^4.22.4",
|
|
61
|
-
"typescript": "^5.9.3"
|
|
70
|
+
"typescript": "^5.9.3",
|
|
71
|
+
"vitest": "^4.1.9"
|
|
62
72
|
}
|
|
63
|
-
}
|
|
73
|
+
}
|