simplyutils-mcp 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/http.js +31 -0
- package/dist/server.js +116 -0
- package/dist/stdio.js +5 -0
- package/dist/tools.js +177 -0
- package/dist/units.js +130 -0
- package/package.json +54 -4
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
|
-
#
|
|
1
|
+
# SimplyUtils MCP server
|
|
2
2
|
|
|
3
|
-
|
|
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,116 @@
|
|
|
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.0", 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 pretty-print or minify it, optionally sorting object keys. Returns the error position for invalid JSON.",
|
|
22
|
+
inputSchema: { json: z.string().describe("The JSON text"), indent: z.enum(["2", "4", "tab", "minify"]).default("2"), sortKeys: z.boolean().default(false) },
|
|
23
|
+
annotations: readOnly,
|
|
24
|
+
}, ({ json, indent, sortKeys }) => run("json-formatter", () => tools.formatJson(json, indent, sortKeys)));
|
|
25
|
+
server.registerTool("generate_qr_code", {
|
|
26
|
+
title: "Generate a QR code",
|
|
27
|
+
description: "Make a static QR code for a link or text (up to 800 bytes). Returns a PNG image and the SVG markup. Static codes never expire.",
|
|
28
|
+
inputSchema: {
|
|
29
|
+
text: z.string().describe("Link or text to encode"),
|
|
30
|
+
errorCorrection: z.enum(["L", "M", "Q", "H"]).default("M"),
|
|
31
|
+
foreground: z.string().default("#000000").describe("Six-digit hex color for the modules"),
|
|
32
|
+
background: z.string().default("#ffffff").describe("Six-digit hex color for the background"),
|
|
33
|
+
},
|
|
34
|
+
annotations: readOnly,
|
|
35
|
+
}, async ({ text: value, errorCorrection, foreground, background }) => {
|
|
36
|
+
try {
|
|
37
|
+
const { svg, pngBase64 } = await tools.qrCode(value, errorCorrection, foreground, background);
|
|
38
|
+
return { content: [
|
|
39
|
+
{ type: "image", data: pngBase64, mimeType: "image/png" },
|
|
40
|
+
{ 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")}` },
|
|
41
|
+
] };
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return { ...text(`Error: ${error.message}`, "qr-code-generator"), isError: true };
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
server.registerTool("convert_units", {
|
|
48
|
+
title: "Convert units",
|
|
49
|
+
description: `Convert a value between units of the same category. Units (symbol or name): ${tools.unitList()}.`,
|
|
50
|
+
inputSchema: {
|
|
51
|
+
value: z.string().describe("A plain number, e.g. 12.5"),
|
|
52
|
+
category: z.enum(tools.unitCategories),
|
|
53
|
+
from: z.string().describe("Unit symbol or name, e.g. km or kilometer"),
|
|
54
|
+
to: z.string().describe("Unit symbol or name, e.g. mi or mile"),
|
|
55
|
+
},
|
|
56
|
+
annotations: readOnly,
|
|
57
|
+
}, ({ value, category, from, to }) => run("unit-converter", () => tools.convertUnits(value, category, from, to).text));
|
|
58
|
+
server.registerTool("generate_password", {
|
|
59
|
+
title: "Generate strong passwords",
|
|
60
|
+
description: "Generate random passwords with a cryptographically secure generator. Each chosen character set appears at least once.",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
length: z.number().int().min(8).max(128).default(16),
|
|
63
|
+
count: z.number().int().min(1).max(20).default(1),
|
|
64
|
+
uppercase: z.boolean().default(true), lowercase: z.boolean().default(true), numbers: z.boolean().default(true), symbols: z.boolean().default(true),
|
|
65
|
+
},
|
|
66
|
+
annotations: { ...readOnly, idempotentHint: false },
|
|
67
|
+
}, ({ length, count, ...sets }) => run("password-generator", () => tools.generatePasswords(length, count, sets).join("\n")));
|
|
68
|
+
server.registerTool("generate_uuid", {
|
|
69
|
+
title: "Generate UUIDs",
|
|
70
|
+
description: "Generate random version 4 UUIDs.",
|
|
71
|
+
inputSchema: { count: z.number().int().min(1).max(50).default(1) },
|
|
72
|
+
annotations: { ...readOnly, idempotentHint: false },
|
|
73
|
+
}, ({ count }) => run("uuid-generator", () => tools.generateUuids(count).join("\n")));
|
|
74
|
+
server.registerTool("hash_text", {
|
|
75
|
+
title: "Hash text",
|
|
76
|
+
description: "Hash UTF-8 text with MD5, SHA-1, SHA-256, SHA-384 or SHA-512 and return the hex digest. MD5 and SHA-1 are for checksums only, not security.",
|
|
77
|
+
inputSchema: { text: z.string(), algorithm: z.enum(["md5", "sha1", "sha256", "sha384", "sha512"]).default("sha256") },
|
|
78
|
+
annotations: readOnly,
|
|
79
|
+
}, ({ text: value, algorithm }) => run("hash-generator", () => `${algorithm}: ${tools.hashText(value, algorithm)}`));
|
|
80
|
+
server.registerTool("base64", {
|
|
81
|
+
title: "Base64 encode or decode",
|
|
82
|
+
description: "Encode UTF-8 text to Base64 (standard or URL-safe) or decode Base64 back to text.",
|
|
83
|
+
inputSchema: { text: z.string(), mode: z.enum(["encode", "decode"]), urlSafe: z.boolean().default(false) },
|
|
84
|
+
annotations: readOnly,
|
|
85
|
+
}, ({ text: value, mode, urlSafe }) => run("base64", () => tools.base64(value, mode, urlSafe)));
|
|
86
|
+
server.registerTool("test_regex", {
|
|
87
|
+
title: "Test a regular expression",
|
|
88
|
+
description: "Run a JavaScript regular expression against text and list every match with its position and capture groups (up to 200 matches, 0.5 s limit).",
|
|
89
|
+
inputSchema: { pattern: z.string().describe("Pattern without slashes"), flags: z.string().default("g").describe("JavaScript flags such as gi"), text: z.string() },
|
|
90
|
+
annotations: readOnly,
|
|
91
|
+
}, ({ pattern, flags, text: value }) => run("regex-tester", () => {
|
|
92
|
+
const matches = tools.testRegex(pattern, flags, value);
|
|
93
|
+
if (!matches.length)
|
|
94
|
+
return "No matches.";
|
|
95
|
+
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");
|
|
96
|
+
}));
|
|
97
|
+
server.registerTool("convert_timestamp", {
|
|
98
|
+
title: "Convert a Unix timestamp or date",
|
|
99
|
+
description: "Convert a Unix timestamp (seconds or milliseconds) or a date string to Unix seconds, milliseconds, ISO 8601, UTC and a chosen time zone.",
|
|
100
|
+
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") },
|
|
101
|
+
annotations: readOnly,
|
|
102
|
+
}, ({ value, timeZone }) => run("timestamp-converter", () => {
|
|
103
|
+
const r = tools.convertTimestamp(value, timeZone);
|
|
104
|
+
return `Unix seconds: ${r.unixSeconds}\nUnix milliseconds: ${r.unixMilliseconds}\nISO 8601: ${r.iso}\nUTC: ${r.utc}\n${r.timeZone}: ${r.local}`;
|
|
105
|
+
}));
|
|
106
|
+
server.registerTool("count_words", {
|
|
107
|
+
title: "Count words and characters",
|
|
108
|
+
description: "Count words, characters, sentences and paragraphs in text, with an estimated reading time.",
|
|
109
|
+
inputSchema: { text: z.string() },
|
|
110
|
+
annotations: readOnly,
|
|
111
|
+
}, ({ text: value }) => run("word-counter", () => {
|
|
112
|
+
const r = tools.countWords(value);
|
|
113
|
+
return `Words: ${r.words}\nCharacters: ${r.characters}\nCharacters (no spaces): ${r.charactersWithoutSpaces}\nSentences: ${r.sentences}\nParagraphs: ${r.paragraphs}\nReading time: about ${r.readingTimeMinutes} min`;
|
|
114
|
+
}));
|
|
115
|
+
return server;
|
|
116
|
+
}
|
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,177 @@
|
|
|
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
|
+
date = new Date(text);
|
|
151
|
+
}
|
|
152
|
+
if (Number.isNaN(date.getTime()))
|
|
153
|
+
throw new Error("Enter a Unix timestamp (seconds or milliseconds) or a date such as 2026-10-27T09:30:00Z.");
|
|
154
|
+
let local;
|
|
155
|
+
try {
|
|
156
|
+
local = new Intl.DateTimeFormat("en-US", { timeZone, dateStyle: "full", timeStyle: "long" }).format(date);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw new Error(`Unknown time zone "${timeZone}". Use an IANA name such as America/New_York.`);
|
|
160
|
+
}
|
|
161
|
+
return { unixSeconds: Math.floor(date.getTime() / 1000), unixMilliseconds: date.getTime(), iso: date.toISOString(), utc: date.toUTCString(), local, timeZone };
|
|
162
|
+
}
|
|
163
|
+
// 10. Word count
|
|
164
|
+
export function countWords(text) {
|
|
165
|
+
limitText(text);
|
|
166
|
+
const words = text.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu) ?? [];
|
|
167
|
+
const sentences = text.split(/[.!?]+(?=\s|$)/).filter(part => /[\p{L}\p{N}]/u.test(part)).length;
|
|
168
|
+
const paragraphs = text.split(/\n\s*\n/).filter(part => part.trim()).length;
|
|
169
|
+
return {
|
|
170
|
+
words: words.length,
|
|
171
|
+
characters: [...text].length,
|
|
172
|
+
charactersWithoutSpaces: [...text.replace(/\s/g, "")].length,
|
|
173
|
+
sentences,
|
|
174
|
+
paragraphs,
|
|
175
|
+
readingTimeMinutes: Math.max(words.length ? 1 : 0, Math.round(words.length / 238)),
|
|
176
|
+
};
|
|
177
|
+
}
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
}
|