simplyutils-mcp 0.0.0-stage → 1.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Memegician LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ # SimplyUtils MCP server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Everyday developer and text tools from [SimplyUtils](https://simplyutils.com) for Claude, ChatGPT, Cursor and any other [Model Context Protocol](https://modelcontextprotocol.io) client.
4
+
5
+ Ask things like *"format this JSON"*, *"make a QR code for https://example.com"*, *"convert 5 miles to km"* or *"test this regex against my log line"*. The answers are computed directly: no AI calls, no external lookups, and nothing is stored. Each answer links to the matching free tool on simplyutils.com, where you can do more with it.
6
+
7
+ ## Tools
8
+
9
+ | Tool | What it does | On the web |
10
+ |---|---|---|
11
+ | `format_json` | Validate, pretty-print or minify JSON, optionally sorting keys | [JSON Formatter](https://simplyutils.com/tool/json-formatter) |
12
+ | `generate_qr_code` | Static QR code for a link or text, as PNG and SVG | [QR Code Generator](https://simplyutils.com/tool/qr-code-generator) |
13
+ | `convert_units` | Length, weight, temperature, volume, speed, area, data and time | [Unit Converter](https://simplyutils.com/tool/unit-converter) |
14
+ | `generate_password` | Cryptographically random passwords (8–128 characters) | [Password Generator](https://simplyutils.com/tool/password-generator) |
15
+ | `generate_uuid` | Random version 4 UUIDs | [UUID Generator](https://simplyutils.com/tool/uuid-generator) |
16
+ | `hash_text` | MD5, SHA-1, SHA-256, SHA-384, SHA-512 hex digests | [Hash Generator](https://simplyutils.com/tool/hash-generator) |
17
+ | `base64` | Encode or decode UTF-8 text (standard or URL-safe) | [Base64 Encoder](https://simplyutils.com/tool/base64) |
18
+ | `test_regex` | Run a JavaScript regex and list matches and capture groups | [Regex Tester](https://simplyutils.com/tool/regex-tester) |
19
+ | `convert_timestamp` | Unix seconds or milliseconds ↔ ISO 8601, UTC and any time zone | [Timestamp Converter](https://simplyutils.com/tool/timestamp-converter) |
20
+ | `count_words` | Words, characters, sentences, paragraphs and reading time | [Word Counter](https://simplyutils.com/tool/word-counter) |
21
+
22
+ All tools are read-only.
23
+
24
+ ## Connect
25
+
26
+ The hosted server is free to use at:
27
+
28
+ ```
29
+ https://api.simplyutils.com/mcp
30
+ ```
31
+
32
+ **Claude (claude.ai and desktop):** Settings → Connectors → *Add custom connector*, then paste the URL above.
33
+
34
+ **Claude Code:**
35
+
36
+ ```bash
37
+ claude mcp add --transport http simplyutils https://api.simplyutils.com/mcp
38
+ ```
39
+
40
+ **ChatGPT:** Settings → Apps & Connectors → enable developer mode → *Create*, then paste the URL above.
41
+
42
+ **Cursor, VS Code and other clients** (`mcp.json`):
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "simplyutils": { "url": "https://api.simplyutils.com/mcp" }
48
+ }
49
+ }
50
+ ```
51
+
52
+ **Run it locally instead** (no network needed after install):
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "simplyutils": { "command": "npx", "args": ["-y", "simplyutils-mcp"] }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Limits
63
+
64
+ - Text inputs up to 100 KB; QR content up to 800 bytes.
65
+ - Regex patterns stop after 0.5 seconds and return at most 200 matches.
66
+ - The hosted endpoint allows 60 requests per minute per IP address.
67
+
68
+ ## Privacy
69
+
70
+ Tool inputs are processed in memory to produce the answer and are not stored or logged by this server. The hosted endpoint runs on SimplyUtils infrastructure; see the [SimplyUtils privacy policy](https://simplyutils.com/privacy).
71
+
72
+ ## Develop
73
+
74
+ ```bash
75
+ npm install
76
+ npm test # unit and end-to-end MCP tests
77
+ npm run build
78
+ npm start # HTTP server on http://127.0.0.1:5016/mcp
79
+ npm run start:stdio
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT © Memegician LLC. SimplyUtils is a product of Memegician LLC.
package/dist/http.js ADDED
@@ -0,0 +1,31 @@
1
+ // Remote MCP endpoint (Streamable HTTP, stateless). Runs behind Nginx at https://api.simplyutils.com/mcp.
2
+ import express from "express";
3
+ import { rateLimit } from "express-rate-limit";
4
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ import { createServer } from "./server.js";
7
+ const port = Number(process.env.PORT ?? 5016);
8
+ const host = process.env.HOST ?? "127.0.0.1";
9
+ const allowedHosts = (process.env.ALLOWED_HOSTS ?? "api.simplyutils.com,localhost,127.0.0.1").split(",").map(h => h.trim()).filter(Boolean);
10
+ const app = createMcpExpressApp({ host, allowedHosts: allowedHosts.flatMap(h => [h, `${h}:${port}`]) });
11
+ app.set("trust proxy", 1); // Nginx in front: rate-limit by the real client IP.
12
+ app.use(express.json({ limit: "200kb" }));
13
+ app.use("/mcp", rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: "draft-7", legacyHeaders: false }));
14
+ app.post("/mcp", async (req, res) => {
15
+ // Stateless: a fresh server and transport per request, closed when the response ends.
16
+ const server = createServer();
17
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
18
+ res.on("close", () => { void transport.close(); void server.close(); });
19
+ try {
20
+ await server.connect(transport);
21
+ await transport.handleRequest(req, res, req.body);
22
+ }
23
+ catch {
24
+ if (!res.headersSent)
25
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
26
+ }
27
+ });
28
+ // No sessions or server-sent streams in stateless mode.
29
+ app.all("/mcp", (_req, res) => res.status(405).set("Allow", "POST").json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null }));
30
+ app.get("/health", (_req, res) => res.json({ ok: true }));
31
+ app.listen(port, host, () => console.log(`SimplyUtils MCP listening on http://${host}:${port}/mcp`));
package/dist/server.js ADDED
@@ -0,0 +1,134 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import * as tools from "./tools.js";
4
+ const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
5
+ const text = (body, slug) => ({
6
+ content: [{ type: "text", text: `${body}\n\nOpen in SimplyUtils: ${tools.toolUrl(slug)}` }],
7
+ });
8
+ /** Tool errors go back to the model as readable text instead of failing the call. */
9
+ async function run(slug, work) {
10
+ try {
11
+ return text(await work(), slug);
12
+ }
13
+ catch (error) {
14
+ return { ...text(`Error: ${error.message}`, slug), isError: true };
15
+ }
16
+ }
17
+ export function createServer() {
18
+ const server = new McpServer({ name: "simplyutils", title: "SimplyUtils", version: "1.0.2", websiteUrl: tools.SITE }, { instructions: "Everyday developer and text utilities from SimplyUtils (simplyutils.com). Results are computed directly, with no AI and no external lookups. Each answer links to the matching free tool on simplyutils.com." });
19
+ server.registerTool("format_json", {
20
+ title: "Format or minify JSON",
21
+ description: "Validate JSON and return it pretty-printed or minified, optionally with object keys sorted A-Z (recursively; array order is kept). Returns the formatted JSON as text. Invalid JSON returns an error naming the problem and its position. Input up to 100 KB.",
22
+ inputSchema: {
23
+ json: z.string().describe("The JSON text to validate and format"),
24
+ indent: z.enum(["2", "4", "tab", "minify"]).default("2").describe("2 or 4 spaces, a tab, or minify to remove all whitespace"),
25
+ sortKeys: z.boolean().default(false).describe("Sort object keys alphabetically at every level"),
26
+ },
27
+ annotations: readOnly,
28
+ }, ({ json, indent, sortKeys }) => run("json-formatter", () => tools.formatJson(json, indent, sortKeys)));
29
+ server.registerTool("generate_qr_code", {
30
+ title: "Generate a QR code",
31
+ description: "Make a static QR code for a link or any text, up to 800 UTF-8 bytes. Returns a 512 px PNG image plus the SVG markup. The data is stored in the code itself, so it never expires and needs no account. Light-on-dark colors are allowed, but some older scanners need dark on light. Errors are returned for empty text, content over 800 bytes, or colors that are not six-digit hex.",
32
+ inputSchema: {
33
+ text: z.string().describe("Link or text to encode"),
34
+ errorCorrection: z.enum(["L", "M", "Q", "H"]).default("M").describe("Error correction: L 7%, M 15%, Q 25%, H 30% of the code can be damaged and still scan; higher is denser"),
35
+ foreground: z.string().default("#000000").describe("Six-digit hex color for the modules"),
36
+ background: z.string().default("#ffffff").describe("Six-digit hex color for the background"),
37
+ },
38
+ annotations: readOnly,
39
+ }, async ({ text: value, errorCorrection, foreground, background }) => {
40
+ try {
41
+ const { svg, pngBase64 } = await tools.qrCode(value, errorCorrection, foreground, background);
42
+ return { content: [
43
+ { type: "image", data: pngBase64, mimeType: "image/png" },
44
+ { type: "text", text: `SVG:\n${svg}\n\nTest the code with your phone before printing. Customize colors and add a logo: ${tools.toolUrl("qr-code-generator")}` },
45
+ ] };
46
+ }
47
+ catch (error) {
48
+ return { ...text(`Error: ${error.message}`, "qr-code-generator"), isError: true };
49
+ }
50
+ });
51
+ server.registerTool("convert_units", {
52
+ title: "Convert units",
53
+ description: `Convert a number between two units of the same category and return a line such as "5 mi = 8.04672 km" (up to 10 significant digits). Units are matched by symbol or name, case-insensitive. Errors are returned for unknown units, units from another category, non-numeric values and temperatures below absolute zero. Months are 1/12 of a 365-day year. Units by category (symbols): ${tools.unitList()}.`,
54
+ inputSchema: {
55
+ value: z.string().describe("A plain number such as 12.5, -40 or 1e6 (no commas or unit text)"),
56
+ category: z.enum(tools.unitCategories).describe("The kind of quantity; both units must belong to it"),
57
+ from: z.string().describe("Unit symbol or name, e.g. km or kilometer"),
58
+ to: z.string().describe("Unit symbol or name, e.g. mi or mile"),
59
+ },
60
+ annotations: readOnly,
61
+ }, ({ value, category, from, to }) => run("unit-converter", () => tools.convertUnits(value, category, from, to).text));
62
+ server.registerTool("generate_password", {
63
+ title: "Generate strong passwords",
64
+ description: "Generate random passwords with Node's cryptographically secure generator. Each enabled character set appears at least once and the result is shuffled. Returns one password per line; every call gives new results. An error is returned if all character sets are turned off. Passwords are not stored or logged.",
65
+ inputSchema: {
66
+ length: z.number().int().min(8).max(128).default(16).describe("Characters per password, 8-128"),
67
+ count: z.number().int().min(1).max(20).default(1).describe("How many passwords to generate, 1-20"),
68
+ uppercase: z.boolean().default(true).describe("Include A-Z"),
69
+ lowercase: z.boolean().default(true).describe("Include a-z"),
70
+ numbers: z.boolean().default(true).describe("Include 0-9"),
71
+ symbols: z.boolean().default(true).describe("Include symbols such as !@#$%^&*"),
72
+ },
73
+ annotations: { ...readOnly, idempotentHint: false },
74
+ }, ({ length, count, ...sets }) => run("password-generator", () => tools.generatePasswords(length, count, sets).join("\n")));
75
+ server.registerTool("generate_uuid", {
76
+ title: "Generate UUIDs",
77
+ description: "Generate random version 4 UUIDs (RFC 9562) in lowercase 8-4-4-4-12 form, one per line. Every call gives new values.",
78
+ inputSchema: { count: z.number().int().min(1).max(50).default(1).describe("How many UUIDs to generate, 1-50") },
79
+ annotations: { ...readOnly, idempotentHint: false },
80
+ }, ({ count }) => run("uuid-generator", () => tools.generateUuids(count).join("\n")));
81
+ server.registerTool("hash_text", {
82
+ title: "Hash text",
83
+ description: "Hash text (encoded as UTF-8) and return a line such as 'sha256: ba78...' with the lowercase hex digest. The same input always gives the same hash. MD5 and SHA-1 are only for checksums, not security; do not use plain hashes to store passwords. Input up to 100 KB.",
84
+ inputSchema: {
85
+ text: z.string().describe("The exact text to hash; spaces and line breaks change the result"),
86
+ algorithm: z.enum(["md5", "sha1", "sha256", "sha384", "sha512"]).default("sha256").describe("Hash algorithm; sha256 is the usual choice"),
87
+ },
88
+ annotations: readOnly,
89
+ }, ({ text: value, algorithm }) => run("hash-generator", () => `${algorithm}: ${tools.hashText(value, algorithm)}`));
90
+ server.registerTool("base64", {
91
+ title: "Base64 encode or decode",
92
+ description: "Encode text (as UTF-8) to Base64, or decode Base64 back to text, and return the result. Decoding accepts standard and URL-safe alphabets and ignores whitespace; invalid Base64 returns an error. Bytes that are not valid UTF-8 are shown as replacement characters. Input up to 100 KB.",
93
+ inputSchema: {
94
+ text: z.string().describe("Text to encode, or Base64 to decode"),
95
+ mode: z.enum(["encode", "decode"]).describe("encode: text to Base64; decode: Base64 to text"),
96
+ urlSafe: z.boolean().default(false).describe("When encoding, use - and _ and drop = padding (for URLs and JWTs)"),
97
+ },
98
+ annotations: readOnly,
99
+ }, ({ text: value, mode, urlSafe }) => run("base64", () => tools.base64(value, mode, urlSafe)));
100
+ server.registerTool("test_regex", {
101
+ title: "Test a regular expression",
102
+ description: "Run a JavaScript (ECMAScript) regular expression against text. Returns the match count and, for each match, the matched text, its 0-based index, numbered capture groups and named groups. Returns 'No matches.' when nothing matches. All matches are listed (the g flag is added automatically), up to 200. Invalid patterns or flags return an error explaining why; patterns that run longer than 0.5 seconds are stopped with an error. Text up to 100 KB.",
103
+ inputSchema: {
104
+ pattern: z.string().describe("The pattern without surrounding slashes, for example (\\d+)-(?<unit>[a-z]+)"),
105
+ flags: z.string().default("g").describe("Any of d, g, i, m, s, u, v, y; for example gi for case-insensitive"),
106
+ text: z.string().describe("The text to search"),
107
+ },
108
+ annotations: readOnly,
109
+ }, ({ pattern, flags, text: value }) => run("regex-tester", () => {
110
+ const matches = tools.testRegex(pattern, flags, value);
111
+ if (!matches.length)
112
+ return "No matches.";
113
+ return `${matches.length} match${matches.length === 1 ? "" : "es"}:\n` + matches.map((m, i) => `${i + 1}. "${m.match}" at ${m.index}` + (m.groups.length ? ` groups: ${JSON.stringify(m.groups)}` : "") + (m.named ? ` named: ${JSON.stringify(m.named)}` : "")).join("\n");
114
+ }));
115
+ server.registerTool("convert_timestamp", {
116
+ title: "Convert a Unix timestamp or date",
117
+ description: "Convert a Unix timestamp or a date to Unix seconds, Unix milliseconds, ISO 8601 (UTC), an HTTP-style UTC string and a readable date in the chosen time zone. Numbers with 13 or more digits are read as milliseconds, shorter ones as seconds. Dates without a time zone are read as UTC. Errors are returned for unreadable dates and unknown time zones.",
118
+ inputSchema: { value: z.string().describe("e.g. 1767225600 or 2026-10-27T09:30:00Z"), timeZone: z.string().default("UTC").describe("IANA time zone, e.g. Asia/Kolkata") },
119
+ annotations: readOnly,
120
+ }, ({ value, timeZone }) => run("timestamp-converter", () => {
121
+ const r = tools.convertTimestamp(value, timeZone);
122
+ return `Unix seconds: ${r.unixSeconds}\nUnix milliseconds: ${r.unixMilliseconds}\nISO 8601: ${r.iso}\nUTC: ${r.utc}\n${r.timeZone}: ${r.local}`;
123
+ }));
124
+ server.registerTool("count_words", {
125
+ title: "Count words and characters",
126
+ description: "Count words, characters (with and without spaces), sentences and paragraphs, and estimate reading time at 238 words per minute. Words are runs of letters or digits in any language; hyphenated words and contractions count once. Paragraphs are separated by blank lines. Input up to 100 KB.",
127
+ inputSchema: { text: z.string().describe("The text to analyze") },
128
+ annotations: readOnly,
129
+ }, ({ text: value }) => run("word-counter", () => {
130
+ const r = tools.countWords(value);
131
+ return `Words: ${r.words}\nCharacters: ${r.characters}\nCharacters (no spaces): ${r.charactersWithoutSpaces}\nSentences: ${r.sentences}\nParagraphs: ${r.paragraphs}\nReading time: about ${r.readingTimeMinutes} min`;
132
+ }));
133
+ return server;
134
+ }
package/dist/stdio.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // Local MCP server over stdio, for `npx simplyutils-mcp` in Claude Desktop, Claude Code, Cursor and others.
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { createServer } from "./server.js";
5
+ await createServer().connect(new StdioServerTransport());
package/dist/tools.js ADDED
@@ -0,0 +1,179 @@
1
+ // Pure tool logic. No network calls and no AI: every result is computed locally.
2
+ import { createHash, randomInt, randomUUID } from "node:crypto";
3
+ import vm from "node:vm";
4
+ import QRCode from "qrcode";
5
+ import { UNITS, convertUnit } from "./units.js";
6
+ export const SITE = "https://simplyutils.com";
7
+ export const toolUrl = (slug) => `${SITE}/tool/${slug}`;
8
+ const MAX_TEXT_BYTES = 100_000;
9
+ function limitText(text, label = "text") {
10
+ if (Buffer.byteLength(text, "utf8") > MAX_TEXT_BYTES)
11
+ throw new Error(`The ${label} is larger than 100 KB. Use a smaller input.`);
12
+ }
13
+ // 1. JSON
14
+ export function formatJson(json, indent = "2", sortKeys = false) {
15
+ limitText(json, "JSON");
16
+ let value;
17
+ try {
18
+ value = JSON.parse(json);
19
+ }
20
+ catch (error) {
21
+ throw new Error(`Invalid JSON: ${error.message}`);
22
+ }
23
+ if (sortKeys)
24
+ value = sortDeep(value);
25
+ const space = indent === "minify" ? undefined : indent === "tab" ? "\t" : Number(indent);
26
+ return JSON.stringify(value, null, space);
27
+ }
28
+ function sortDeep(value) {
29
+ if (Array.isArray(value))
30
+ return value.map(sortDeep);
31
+ if (value && typeof value === "object") {
32
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, sortDeep(value[key])]));
33
+ }
34
+ return value;
35
+ }
36
+ // 2. QR code
37
+ export async function qrCode(text, errorCorrection = "M", foreground = "#000000", background = "#ffffff") {
38
+ if (!text)
39
+ throw new Error("Enter the text or link to encode.");
40
+ if (Buffer.byteLength(text, "utf8") > 800)
41
+ throw new Error("QR content is limited to 800 UTF-8 bytes so the code stays easy to scan.");
42
+ for (const color of [foreground, background])
43
+ if (!/^#[\da-f]{6}$/i.test(color))
44
+ throw new Error("Use six-digit hex colors such as #000000.");
45
+ const options = { errorCorrectionLevel: errorCorrection, margin: 4, color: { dark: foreground, light: background } };
46
+ const [svg, png] = await Promise.all([
47
+ QRCode.toString(text, { ...options, type: "svg" }),
48
+ QRCode.toBuffer(text, { ...options, type: "png", width: 512 }),
49
+ ]);
50
+ return { svg, pngBase64: png.toString("base64") };
51
+ }
52
+ // 3. Units
53
+ export const unitCategories = Object.keys(UNITS);
54
+ export function unitList() {
55
+ return unitCategories.map(category => `${category}: ${UNITS[category].units.map(unit => unit.symbol).join(", ")}`).join("; ");
56
+ }
57
+ function unitSymbol(category, unit) {
58
+ const wanted = unit.trim().toLowerCase();
59
+ const match = UNITS[category].units.find(candidate => candidate.symbol.toLowerCase() === wanted || candidate.label.toLowerCase() === wanted || candidate.label.toLowerCase() + "s" === wanted);
60
+ if (!match)
61
+ throw new Error(`Unknown ${category} unit "${unit}". Use one of: ${UNITS[category].units.map(u => u.symbol).join(", ")}.`);
62
+ return match;
63
+ }
64
+ export function convertUnits(value, category, from, to) {
65
+ const source = unitSymbol(category, from), target = unitSymbol(category, to);
66
+ const result = convertUnit(value, category, source.symbol, target.symbol);
67
+ return { result, text: `${value} ${source.symbol} = ${result} ${target.symbol}` };
68
+ }
69
+ // 4. Passwords
70
+ const SETS = { uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", lowercase: "abcdefghijklmnopqrstuvwxyz", numbers: "0123456789", symbols: "!@#$%^&*()-_=+[]{};:,.?/" };
71
+ export function generatePasswords(length = 16, count = 1, sets = {}) {
72
+ const chosen = Object.keys(SETS).filter(key => sets[key] ?? true);
73
+ if (!chosen.length)
74
+ throw new Error("Choose at least one character set.");
75
+ if (length < chosen.length)
76
+ throw new Error("The length must allow one character from each chosen set.");
77
+ const pool = chosen.map(key => SETS[key]).join("");
78
+ return Array.from({ length: count }, () => {
79
+ // One character from each chosen set, the rest from the whole pool, then shuffle.
80
+ const chars = chosen.map(key => SETS[key][randomInt(SETS[key].length)]);
81
+ while (chars.length < length)
82
+ chars.push(pool[randomInt(pool.length)]);
83
+ for (let i = chars.length - 1; i > 0; i--) {
84
+ const j = randomInt(i + 1);
85
+ [chars[i], chars[j]] = [chars[j], chars[i]];
86
+ }
87
+ return chars.join("");
88
+ });
89
+ }
90
+ // 5. UUIDs
91
+ export const generateUuids = (count = 1) => Array.from({ length: count }, () => randomUUID());
92
+ // 6. Hashes
93
+ export function hashText(text, algorithm = "sha256") {
94
+ limitText(text);
95
+ return createHash(algorithm).update(text, "utf8").digest("hex");
96
+ }
97
+ // 7. Base64
98
+ export function base64(text, mode, urlSafe = false) {
99
+ limitText(text);
100
+ if (mode === "encode") {
101
+ const encoded = Buffer.from(text, "utf8").toString("base64");
102
+ return urlSafe ? encoded.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") : encoded;
103
+ }
104
+ const clean = text.replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/");
105
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean) || clean.replace(/=+$/, "").length % 4 === 1)
106
+ throw new Error("This is not valid Base64.");
107
+ const bytes = Buffer.from(clean, "base64");
108
+ const decoded = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
109
+ return decoded;
110
+ }
111
+ // 8. Regex, run with a time limit so a slow pattern cannot block the server.
112
+ export function testRegex(pattern, flags, text) {
113
+ limitText(text);
114
+ if (pattern.length > 2000)
115
+ throw new Error("The pattern is longer than 2,000 characters.");
116
+ if (!/^[dgimsuyv]*$/.test(flags) || new Set(flags).size !== flags.length)
117
+ throw new Error("Use JavaScript regex flags: d, g, i, m, s, u, v, y.");
118
+ let regex;
119
+ try {
120
+ regex = new RegExp(pattern, flags.includes("g") ? flags : flags + "g");
121
+ }
122
+ catch (error) {
123
+ throw new Error(`Invalid pattern: ${error.message}`);
124
+ }
125
+ const context = { regex, text, out: [] };
126
+ try {
127
+ vm.runInNewContext(`
128
+ for (const m of text.matchAll(regex)) {
129
+ out.push({ match: m[0], index: m.index, groups: m.slice(1).map(g => g === undefined ? null : g), named: m.groups ? { ...m.groups } : undefined });
130
+ if (out.length >= 200) break;
131
+ }`, context, { timeout: 500 });
132
+ }
133
+ catch (error) {
134
+ if (/timed out/i.test(String(error)))
135
+ throw new Error("The pattern took longer than 0.5 seconds (likely catastrophic backtracking). Simplify it.");
136
+ throw error;
137
+ }
138
+ return context.out;
139
+ }
140
+ // 9. Timestamps
141
+ export function convertTimestamp(value, timeZone = "UTC") {
142
+ const text = value.trim();
143
+ let date;
144
+ if (/^-?\d+(\.\d+)?$/.test(text)) {
145
+ const number = Number(text);
146
+ // 13+ digits are milliseconds; shorter values are seconds.
147
+ date = new Date(Math.abs(number) >= 1e12 ? number : number * 1000);
148
+ }
149
+ else {
150
+ // An ISO date-time without a zone would otherwise use the server's local zone; read it as UTC.
151
+ const isoWithoutZone = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(text);
152
+ date = new Date(isoWithoutZone ? text.replace(" ", "T") + "Z" : text);
153
+ }
154
+ if (Number.isNaN(date.getTime()))
155
+ throw new Error("Enter a Unix timestamp (seconds or milliseconds) or a date such as 2026-10-27T09:30:00Z.");
156
+ let local;
157
+ try {
158
+ local = new Intl.DateTimeFormat("en-US", { timeZone, dateStyle: "full", timeStyle: "long" }).format(date);
159
+ }
160
+ catch {
161
+ throw new Error(`Unknown time zone "${timeZone}". Use an IANA name such as America/New_York.`);
162
+ }
163
+ return { unixSeconds: Math.floor(date.getTime() / 1000), unixMilliseconds: date.getTime(), iso: date.toISOString(), utc: date.toUTCString(), local, timeZone };
164
+ }
165
+ // 10. Word count
166
+ export function countWords(text) {
167
+ limitText(text);
168
+ const words = text.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu) ?? [];
169
+ const sentences = text.split(/[.!?]+(?=\s|$)/).filter(part => /[\p{L}\p{N}]/u.test(part)).length;
170
+ const paragraphs = text.split(/\n\s*\n/).filter(part => part.trim()).length;
171
+ return {
172
+ words: words.length,
173
+ characters: [...text].length,
174
+ charactersWithoutSpaces: [...text.replace(/\s/g, "")].length,
175
+ sentences,
176
+ paragraphs,
177
+ readingTimeMinutes: Math.max(words.length ? 1 : 0, Math.round(words.length / 238)),
178
+ };
179
+ }
package/dist/units.js ADDED
@@ -0,0 +1,130 @@
1
+ // Same tables and rules as the converter at https://simplyutils.com/tool/unit-converter
2
+ function finiteDecimal(input, label) {
3
+ const text = input.trim();
4
+ if (!text || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(text)) {
5
+ throw new Error(`Enter a valid number for ${label}.`);
6
+ }
7
+ const value = Number(text);
8
+ if (!Number.isFinite(value))
9
+ throw new Error(`${label} must be a finite number.`);
10
+ return value;
11
+ }
12
+ /** Up to 10 significant digits, like the website. */
13
+ function formatCalculatorNumber(value) {
14
+ return Number(value.toPrecision(10)).toString();
15
+ }
16
+ export const UNITS = {
17
+ length: {
18
+ label: "Length",
19
+ units: [
20
+ { label: "Meter", symbol: "m", toBase: v => v, fromBase: v => v },
21
+ { label: "Kilometer", symbol: "km", toBase: v => v * 1000, fromBase: v => v / 1000 },
22
+ { label: "Mile", symbol: "mi", toBase: v => v * 1609.344, fromBase: v => v / 1609.344 },
23
+ { label: "Foot", symbol: "ft", toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
24
+ { label: "Inch", symbol: "in", toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
25
+ { label: "Centimeter", symbol: "cm", toBase: v => v / 100, fromBase: v => v * 100 },
26
+ { label: "Millimeter", symbol: "mm", toBase: v => v / 1000, fromBase: v => v * 1000 },
27
+ { label: "Yard", symbol: "yd", toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
28
+ { label: "Nautical Mile", symbol: "nmi", toBase: v => v * 1852, fromBase: v => v / 1852 },
29
+ ],
30
+ },
31
+ weight: {
32
+ label: "Weight",
33
+ units: [
34
+ { label: "Kilogram", symbol: "kg", toBase: v => v, fromBase: v => v },
35
+ { label: "Gram", symbol: "g", toBase: v => v / 1000, fromBase: v => v * 1000 },
36
+ { label: "Pound", symbol: "lb", toBase: v => v * 0.45359237, fromBase: v => v / 0.45359237 },
37
+ { label: "Ounce", symbol: "oz", toBase: v => v * 0.028349523125, fromBase: v => v / 0.028349523125 },
38
+ { label: "Metric Ton", symbol: "t", toBase: v => v * 1000, fromBase: v => v / 1000 },
39
+ { label: "Milligram", symbol: "mg", toBase: v => v / 1e6, fromBase: v => v * 1e6 },
40
+ { label: "Stone", symbol: "st", toBase: v => v * 6.35029318, fromBase: v => v / 6.35029318 },
41
+ ],
42
+ },
43
+ temperature: {
44
+ label: "Temperature",
45
+ units: [
46
+ { label: "Celsius", symbol: "°C", toBase: v => v, fromBase: v => v },
47
+ { label: "Fahrenheit", symbol: "°F", toBase: v => (v - 32) * 5 / 9, fromBase: v => v * 9 / 5 + 32 },
48
+ { label: "Kelvin", symbol: "K", toBase: v => v - 273.15, fromBase: v => v + 273.15 },
49
+ ],
50
+ },
51
+ volume: {
52
+ label: "Volume",
53
+ units: [
54
+ { label: "Liter", symbol: "L", toBase: v => v, fromBase: v => v },
55
+ { label: "Milliliter", symbol: "mL", toBase: v => v / 1000, fromBase: v => v * 1000 },
56
+ { label: "Gallon (US)", symbol: "gal", toBase: v => v * 3.785411784, fromBase: v => v / 3.785411784 },
57
+ { label: "Fluid Ounce (US)", symbol: "fl oz", toBase: v => v * 0.0295735295625, fromBase: v => v / 0.0295735295625 },
58
+ { label: "Cup (US customary)", symbol: "cup", toBase: v => v * 0.2365882365, fromBase: v => v / 0.2365882365 },
59
+ { label: "Tablespoon (US)", symbol: "tbsp", toBase: v => v * 0.01478676478125, fromBase: v => v / 0.01478676478125 },
60
+ { label: "Cubic Meter", symbol: "m³", toBase: v => v * 1000, fromBase: v => v / 1000 },
61
+ ],
62
+ },
63
+ speed: {
64
+ label: "Speed",
65
+ units: [
66
+ { label: "km/h", symbol: "km/h", toBase: v => v, fromBase: v => v },
67
+ { label: "mph", symbol: "mph", toBase: v => v * 1.609344, fromBase: v => v / 1.609344 },
68
+ { label: "m/s", symbol: "m/s", toBase: v => v * 3.6, fromBase: v => v / 3.6 },
69
+ { label: "Knot", symbol: "kn", toBase: v => v * 1.852, fromBase: v => v / 1.852 },
70
+ { label: "ft/s", symbol: "ft/s", toBase: v => v * 1.09728, fromBase: v => v / 1.09728 },
71
+ ],
72
+ },
73
+ area: {
74
+ label: "Area",
75
+ units: [
76
+ { label: "Square Meter", symbol: "m²", toBase: v => v, fromBase: v => v },
77
+ { label: "Square Kilometer", symbol: "km²", toBase: v => v * 1e6, fromBase: v => v / 1e6 },
78
+ { label: "Square Mile", symbol: "mi²", toBase: v => v * 2589988.110336, fromBase: v => v / 2589988.110336 },
79
+ { label: "Square Foot", symbol: "ft²", toBase: v => v * 0.09290304, fromBase: v => v / 0.09290304 },
80
+ { label: "Acre", symbol: "ac", toBase: v => v * 4046.8564224, fromBase: v => v / 4046.8564224 },
81
+ { label: "Hectare", symbol: "ha", toBase: v => v * 10000, fromBase: v => v / 10000 },
82
+ ],
83
+ },
84
+ data: {
85
+ label: "Data",
86
+ units: [
87
+ { label: "Byte", symbol: "B", toBase: v => v, fromBase: v => v },
88
+ { label: "Kilobyte", symbol: "KB", toBase: v => v * 1000, fromBase: v => v / 1000 },
89
+ { label: "Megabyte", symbol: "MB", toBase: v => v * 1e6, fromBase: v => v / 1e6 },
90
+ { label: "Gigabyte", symbol: "GB", toBase: v => v * 1e9, fromBase: v => v / 1e9 },
91
+ { label: "Terabyte", symbol: "TB", toBase: v => v * 1e12, fromBase: v => v / 1e12 },
92
+ { label: "Bit", symbol: "bit", toBase: v => v / 8, fromBase: v => v * 8 },
93
+ { label: "Kibibyte", symbol: "KiB", toBase: v => v * 1024, fromBase: v => v / 1024 },
94
+ { label: "Mebibyte", symbol: "MiB", toBase: v => v * 2 ** 20, fromBase: v => v / 2 ** 20 },
95
+ { label: "Gibibyte", symbol: "GiB", toBase: v => v * 2 ** 30, fromBase: v => v / 2 ** 30 },
96
+ { label: "Tebibyte", symbol: "TiB", toBase: v => v * 2 ** 40, fromBase: v => v / 2 ** 40 },
97
+ ],
98
+ },
99
+ time: {
100
+ label: "Time",
101
+ units: [
102
+ { label: "Second", symbol: "s", toBase: v => v, fromBase: v => v },
103
+ { label: "Minute", symbol: "min", toBase: v => v * 60, fromBase: v => v / 60 },
104
+ { label: "Hour", symbol: "h", toBase: v => v * 3600, fromBase: v => v / 3600 },
105
+ { label: "Day", symbol: "d", toBase: v => v * 86400, fromBase: v => v / 86400 },
106
+ { label: "Week", symbol: "wk", toBase: v => v * 604800, fromBase: v => v / 604800 },
107
+ { label: "Month (1/12 of 365 days)", symbol: "mo", toBase: v => v * 2628000, fromBase: v => v / 2628000 },
108
+ { label: "Year (365 days)", symbol: "yr", toBase: v => v * 31536000, fromBase: v => v / 31536000 },
109
+ { label: "Millisecond", symbol: "ms", toBase: v => v / 1000, fromBase: v => v * 1000 },
110
+ ],
111
+ },
112
+ };
113
+ export function convertUnit(input, category, from, to) {
114
+ const value = finiteDecimal(input, "the value");
115
+ const units = UNITS[category]?.units;
116
+ const source = units?.find(unit => unit.symbol === from), target = units?.find(unit => unit.symbol === to);
117
+ if (!source || !target)
118
+ throw new Error("Choose valid units from the same category.");
119
+ let base = source.toBase(value);
120
+ if (category === "temperature") {
121
+ const minimum = from === "K" ? 0 : from === "\u00b0F" ? -459.67 : -273.15;
122
+ if (value < minimum)
123
+ throw new Error("Temperature must be at or above absolute zero (-273.15 Celsius / 0 Kelvin).");
124
+ base = Math.max(-273.15, base);
125
+ }
126
+ const result = from === to ? value : target.fromBase(base);
127
+ if (!Number.isFinite(result) || !Number.isFinite(base))
128
+ throw new Error("The conversion exceeds the numeric range. Use a smaller value.");
129
+ return formatCalculatorNumber(result);
130
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "simplyutils-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.2",
4
+ "description": "MCP server with everyday developer and text tools from SimplyUtils: JSON formatter, QR codes, unit converter, passwords, UUIDs, hashes, Base64, regex tester, timestamps and word counts.",
5
+ "main": "dist/server.js",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "start": "node dist/http.js",
9
+ "start:stdio": "node dist/stdio.js",
10
+ "test": "node --import tsx --test test/*.test.ts"
11
+ },
12
+ "keywords": [
13
+ "mcp",
14
+ "model-context-protocol",
15
+ "claude",
16
+ "chatgpt",
17
+ "json",
18
+ "qr-code",
19
+ "unit-converter",
20
+ "developer-tools"
21
+ ],
22
+ "author": "Memegician LLC (https://simplyutils.com)",
23
+ "license": "MIT",
24
+ "type": "module",
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.32.2",
27
+ "express": "^5.3.0",
28
+ "express-rate-limit": "^8.7.1",
29
+ "qrcode": "^1.5.4",
30
+ "zod": "^4.6.7"
31
+ },
32
+ "devDependencies": {
33
+ "@types/express": "^5.0.6",
34
+ "@types/node": "^26.6.5",
35
+ "@types/qrcode": "^1.5.6",
36
+ "tsx": "^4.23.15",
37
+ "typescript": "^7.0.2"
38
+ },
39
+ "bin": {
40
+ "simplyutils-mcp": "dist/stdio.js"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "homepage": "https://simplyutils.com",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/Notysoty/simplyutils-mcp.git"
51
+ },
52
+ "engines": {
53
+ "node": ">=20"
54
+ },
55
+ "mcpName": "io.github.Notysoty/simplyutils-mcp"
56
+ }