@gera-services/mcp-geratools 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/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # GeraTools MCP Server
2
+
3
+ [![A Gera Systems product](https://img.shields.io/badge/Gera-Systems-0a7?style=flat)](https://geratools.com)
4
+
5
+ An [MCP](https://modelcontextprotocol.io) server that gives AI agents — Claude
6
+ Desktop, Claude Code, ChatGPT with tools, Cursor, Windsurf, or any MCP client —
7
+ a set of everyday **developer utilities** from
8
+ [GeraTools](https://geratools.com): Base64, cryptographic hashing, regex
9
+ testing, word count, JWT decoding and CSS unit conversion.
10
+
11
+ Every tool is a pure, deterministic function ported straight from the published
12
+ GeraTools browser tools (or Node's `crypto`). **No backend, no network, no
13
+ auth** — everything runs locally, so an agent can compute these without leaving
14
+ the machine.
15
+
16
+ ## Tools
17
+
18
+ | Tool | What it does |
19
+ |------|--------------|
20
+ | `base64` | Encode text/hex to Base64, or decode Base64 / Base64url to text. UTF-8 safe (emoji round-trip), standard + URL-safe variants, hex fallback for non-text bytes. |
21
+ | `hash` | Cryptographic digest: `md5`, `sha1`, `sha256`, `sha384`, `sha512`. Input as utf8/hex/base64; output as hex/base64. |
22
+ | `regex_test` | Run a JS regex against a string — returns every match with index, capture groups, named groups, count, and a plain-English explanation of the pattern. |
23
+ | `word_count` | Words, characters (with/without spaces), sentences, paragraphs, lines, and 200-wpm reading time. |
24
+ | `jwt_decode` | Base64url-decode a JWT into header + payload, list `iat`/`nbf`/`exp` as ISO timestamps, and report expiry. (Does **not** verify the signature.) |
25
+ | `css_unit_convert` | Convert a CSS length between `px`, `rem`, `em`, `pt`, `pc`, `in`, `cm`, `mm`, `Q` (1in = 96px; rem/em respect configurable font sizes). |
26
+
27
+ ## Install & run
28
+
29
+ ```bash
30
+ # Run directly (no global install) once published to npm:
31
+ npx -y @gera-services/mcp-geratools
32
+
33
+ # Or from this repo:
34
+ cd packages/mcp-geratools
35
+ npm run build # tsc --noCheck -> dist/
36
+ node bin/cli.js # starts on stdio
37
+ ```
38
+
39
+ ## Client configuration
40
+
41
+ ### Claude Desktop / Claude Code (`claude_desktop_config.json`)
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "geratools": {
47
+ "command": "npx",
48
+ "args": ["-y", "@gera-services/mcp-geratools"]
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ Local (unpublished) variant — point at the built CLI:
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "geratools": {
60
+ "command": "node",
61
+ "args": ["/Users/armen/Gera/packages/mcp-geratools/bin/cli.js"]
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ### Cursor / Windsurf (`.cursor/mcp.json` etc.)
68
+
69
+ ```json
70
+ {
71
+ "mcpServers": {
72
+ "geratools": {
73
+ "command": "npx",
74
+ "args": ["-y", "@gera-services/mcp-geratools"]
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ## Verify it works
81
+
82
+ ```bash
83
+ npm run build
84
+ node scripts/smoke.mjs
85
+ ```
86
+
87
+ The smoke test speaks raw MCP JSON-RPC over stdio (`initialize` → `tools/list`
88
+ → a real `tools/call` for every tool) and asserts the results — including a
89
+ Base64 emoji round-trip, the known SHA-256 of `"abc"`, and `16px == 1rem`.
90
+ Expected output ends with `ALL SMOKE CHECKS PASSED`.
91
+
92
+ ## Examples
93
+
94
+ Ask your agent:
95
+
96
+ - *"Base64-encode this string for me"* → `base64` (mode `encode`)
97
+ - *"What's the SHA-256 of this file's contents?"* → `hash` (`sha256`)
98
+ - *"Does `\b\w+@\w+\.\w+\b` match these two emails, and what does it mean?"* → `regex_test`
99
+ - *"Decode this JWT and tell me when it expires"* → `jwt_decode`
100
+ - *"How many rem is 24px at a 18px root?"* → `css_unit_convert`
101
+
102
+ ## License
103
+
104
+ MIT © Gera Systems Ltd
package/bin/cli.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point for the GeraTools MCP server.
4
+ * Starts the server on stdio. Intended to be launched by an MCP client
5
+ * (Claude Desktop, ChatGPT-with-tools, Cursor, etc.) — not run interactively.
6
+ */
7
+ import { main } from '../dist/server.js';
8
+
9
+ main().catch((err) => {
10
+ console.error('Fatal:', err);
11
+ process.exit(1);
12
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * GeraTools computational engine.
3
+ *
4
+ * Pure, offline functions ported directly from the real GeraTools browser
5
+ * islands (utility-tools/src/islands/*). No network, no DOM, no side effects —
6
+ * the same logic the published tools run, made callable from Node so an MCP
7
+ * client can use them headlessly.
8
+ *
9
+ * Sources:
10
+ * - base64 ← Base64EncoderDecoder.tsx
11
+ * - regex ← RegexTester.tsx
12
+ * - word count ← WordCounter.tsx
13
+ * - jwt decode ← JwtDecode.tsx
14
+ * - css units ← CssUnitConverter.tsx
15
+ * - hashing ← uses Node's crypto (the browser tools use SubtleCrypto;
16
+ * same algorithms, same digests)
17
+ */
18
+ export declare function bytesToBase64(bytes: Uint8Array, url: boolean): string;
19
+ export declare function base64ToBytes(input: string): Uint8Array;
20
+ export type Base64Mode = 'encode' | 'decode';
21
+ export type Base64Variant = 'std' | 'url';
22
+ export type Base64InputKind = 'text' | 'hex';
23
+ export interface Base64Result {
24
+ mode: Base64Mode;
25
+ variant: Base64Variant | null;
26
+ output: string;
27
+ output_kind: 'base64' | 'text' | 'hex';
28
+ note: string | null;
29
+ }
30
+ /** Binary-safe Base64 encode/decode, ported from the GeraTools browser tool. */
31
+ export declare function base64(input: string, mode: Base64Mode, opts?: {
32
+ variant?: Base64Variant;
33
+ inputKind?: Base64InputKind;
34
+ }): Base64Result;
35
+ export declare const SUPPORTED_HASHES: readonly ["md5", "sha1", "sha256", "sha384", "sha512"];
36
+ export type HashAlgo = (typeof SUPPORTED_HASHES)[number];
37
+ export interface HashResult {
38
+ algorithm: HashAlgo;
39
+ input_encoding: 'utf8' | 'hex' | 'base64';
40
+ output_encoding: 'hex' | 'base64';
41
+ digest: string;
42
+ digest_bits: number;
43
+ }
44
+ /** Cryptographic digest of arbitrary input. Real Node crypto, deterministic. */
45
+ export declare function hashText(input: string, algorithm: HashAlgo, opts?: {
46
+ inputEncoding?: 'utf8' | 'hex' | 'base64';
47
+ outputEncoding?: 'hex' | 'base64';
48
+ }): HashResult;
49
+ export declare function explainRegex(pattern: string): string[];
50
+ export interface RegexMatch {
51
+ match: string;
52
+ index: number;
53
+ groups: string[];
54
+ namedGroups: Record<string, string> | null;
55
+ }
56
+ export interface RegexResult {
57
+ pattern: string;
58
+ flags: string;
59
+ valid: boolean;
60
+ error: string | null;
61
+ count: number;
62
+ matches: RegexMatch[];
63
+ explanation: string[];
64
+ }
65
+ /** Run a JS regex against a test string and explain it, ported from the tool. */
66
+ export declare function regexTest(pattern: string, test: string, flags?: string): RegexResult;
67
+ export interface WordCountResult {
68
+ words: number;
69
+ charsWithSpaces: number;
70
+ charsNoSpaces: number;
71
+ sentences: number;
72
+ paragraphs: number;
73
+ lines: number;
74
+ readingTimeSeconds: number;
75
+ readingTimeHuman: string;
76
+ }
77
+ /** Word / character / sentence / paragraph stats, ported from the tool. */
78
+ export declare function wordCount(text: string): WordCountResult;
79
+ export interface JwtDecodeResult {
80
+ header: Record<string, unknown>;
81
+ payload: Record<string, unknown>;
82
+ signature: string;
83
+ timeClaims: {
84
+ claim: string;
85
+ epoch: number;
86
+ iso: string;
87
+ }[];
88
+ expired: boolean | null;
89
+ note: string;
90
+ }
91
+ /** Decode (NOT verify) a JWT into header/payload + time claims. */
92
+ export declare function jwtDecode(token: string): JwtDecodeResult;
93
+ export declare const CSS_UNITS: readonly ["px", "rem", "em", "pt", "pc", "in", "cm", "mm", "Q"];
94
+ export type CssUnit = (typeof CSS_UNITS)[number];
95
+ export interface CssUnitResult {
96
+ value: number;
97
+ from: CssUnit;
98
+ rootFontPx: number;
99
+ parentFontPx: number;
100
+ px: number;
101
+ conversions: Record<string, number>;
102
+ }
103
+ /** Convert a CSS length to every other unit, ported from the tool. */
104
+ export declare function cssUnitConvert(value: number, from: CssUnit, opts?: {
105
+ rootFontPx?: number;
106
+ parentFontPx?: number;
107
+ }): CssUnitResult;
package/dist/engine.js ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * GeraTools computational engine.
3
+ *
4
+ * Pure, offline functions ported directly from the real GeraTools browser
5
+ * islands (utility-tools/src/islands/*). No network, no DOM, no side effects —
6
+ * the same logic the published tools run, made callable from Node so an MCP
7
+ * client can use them headlessly.
8
+ *
9
+ * Sources:
10
+ * - base64 ← Base64EncoderDecoder.tsx
11
+ * - regex ← RegexTester.tsx
12
+ * - word count ← WordCounter.tsx
13
+ * - jwt decode ← JwtDecode.tsx
14
+ * - css units ← CssUnitConverter.tsx
15
+ * - hashing ← uses Node's crypto (the browser tools use SubtleCrypto;
16
+ * same algorithms, same digests)
17
+ */
18
+ import { createHash, getHashes } from 'node:crypto';
19
+ // ── Base64 (ported from Base64EncoderDecoder.tsx) ────────────────────────────
20
+ const STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
21
+ export function bytesToBase64(bytes, url) {
22
+ let out = '';
23
+ for (let i = 0; i < bytes.length; i += 3) {
24
+ const b0 = bytes[i];
25
+ const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
26
+ const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
27
+ const triple = (b0 << 16) | (b1 << 8) | b2;
28
+ out += STD[(triple >> 18) & 0x3f];
29
+ out += STD[(triple >> 12) & 0x3f];
30
+ out += i + 1 < bytes.length ? STD[(triple >> 6) & 0x3f] : '=';
31
+ out += i + 2 < bytes.length ? STD[triple & 0x3f] : '=';
32
+ }
33
+ if (url) {
34
+ return out.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
35
+ }
36
+ return out;
37
+ }
38
+ export function base64ToBytes(input) {
39
+ // Accept both standard and url-safe, restore padding.
40
+ let s = input.trim().replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/');
41
+ while (s.length % 4 !== 0)
42
+ s += '=';
43
+ const lookup = new Int16Array(256).fill(-1);
44
+ for (let i = 0; i < STD.length; i++)
45
+ lookup[STD.charCodeAt(i)] = i;
46
+ const clean = s.replace(/=+$/, '');
47
+ const outLen = Math.floor((clean.length * 6) / 8);
48
+ const out = new Uint8Array(outLen);
49
+ let bits = 0;
50
+ let acc = 0;
51
+ let o = 0;
52
+ for (let i = 0; i < clean.length; i++) {
53
+ const v = lookup[clean.charCodeAt(i)];
54
+ if (v < 0)
55
+ throw new Error(`Invalid Base64 character at position ${i + 1}.`);
56
+ acc = (acc << 6) | v;
57
+ bits += 6;
58
+ if (bits >= 8) {
59
+ bits -= 8;
60
+ out[o++] = (acc >> bits) & 0xff;
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ function hexToBytes(hex) {
66
+ const clean = hex.replace(/0x/gi, '').replace(/[\s,]+/g, '');
67
+ if (clean.length % 2 !== 0)
68
+ throw new Error('Hex must have an even number of digits.');
69
+ if (!/^[0-9a-fA-F]*$/.test(clean))
70
+ throw new Error('Hex contains non-hexadecimal characters.');
71
+ const out = new Uint8Array(clean.length / 2);
72
+ for (let i = 0; i < out.length; i++) {
73
+ out[i] = parseInt(clean.substr(i * 2, 2), 16);
74
+ }
75
+ return out;
76
+ }
77
+ function detectPayload(s) {
78
+ const t = s.trim();
79
+ if (/^data:/i.test(t))
80
+ return 'Looks like a data: URI — decode the part after the comma.';
81
+ if (/^-----BEGIN /.test(t))
82
+ return 'Looks like a PEM block — the Base64 body sits between the BEGIN/END armor lines.';
83
+ if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/.test(t))
84
+ return 'Looks like a JWT (three Base64url segments). Use the jwt_decode tool to read the header and payload.';
85
+ return null;
86
+ }
87
+ /** Binary-safe Base64 encode/decode, ported from the GeraTools browser tool. */
88
+ export function base64(input, mode, opts = {}) {
89
+ const variant = opts.variant ?? 'std';
90
+ const inputKind = opts.inputKind ?? 'text';
91
+ if (mode === 'encode') {
92
+ const bytes = inputKind === 'hex' ? hexToBytes(input) : new TextEncoder().encode(input);
93
+ return {
94
+ mode,
95
+ variant,
96
+ output: bytesToBase64(bytes, variant === 'url'),
97
+ output_kind: 'base64',
98
+ note: null,
99
+ };
100
+ }
101
+ // decode
102
+ const bytes = base64ToBytes(input);
103
+ let text;
104
+ try {
105
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
106
+ }
107
+ catch {
108
+ const hex = Array.from(bytes)
109
+ .map((b) => b.toString(16).padStart(2, '0'))
110
+ .join(' ');
111
+ return {
112
+ mode,
113
+ variant: null,
114
+ output: hex,
115
+ output_kind: 'hex',
116
+ note: 'Decoded bytes are not valid UTF-8 text — returning raw hex instead.',
117
+ };
118
+ }
119
+ return { mode, variant: null, output: text, output_kind: 'text', note: detectPayload(input) };
120
+ }
121
+ // ── Hashing (Node crypto — same algorithms the browser SubtleCrypto uses) ─────
122
+ export const SUPPORTED_HASHES = ['md5', 'sha1', 'sha256', 'sha384', 'sha512'];
123
+ /** Cryptographic digest of arbitrary input. Real Node crypto, deterministic. */
124
+ export function hashText(input, algorithm, opts = {}) {
125
+ const inputEncoding = opts.inputEncoding ?? 'utf8';
126
+ const outputEncoding = opts.outputEncoding ?? 'hex';
127
+ if (!getHashes().includes(algorithm) && algorithm !== 'md5') {
128
+ throw new Error(`Algorithm "${algorithm}" is not available on this platform.`);
129
+ }
130
+ const buf = Buffer.from(input, inputEncoding);
131
+ const digest = createHash(algorithm).update(buf).digest(outputEncoding);
132
+ // digest length in bits = hex chars * 4
133
+ const hexLen = createHash(algorithm).update(buf).digest('hex').length;
134
+ return {
135
+ algorithm,
136
+ input_encoding: inputEncoding,
137
+ output_encoding: outputEncoding,
138
+ digest,
139
+ digest_bits: hexLen * 4,
140
+ };
141
+ }
142
+ // ── Regex tester (ported from RegexTester.tsx) ───────────────────────────────
143
+ // A lightweight token-by-token plain-English explainer.
144
+ export function explainRegex(pattern) {
145
+ const out = [];
146
+ let i = 0;
147
+ const classNames = {
148
+ '\\d': 'any digit (0-9)',
149
+ '\\D': 'any non-digit',
150
+ '\\w': 'any word character (letter, digit or underscore)',
151
+ '\\W': 'any non-word character',
152
+ '\\s': 'any whitespace',
153
+ '\\S': 'any non-whitespace',
154
+ '\\b': 'a word boundary',
155
+ '\\B': 'a non-word-boundary',
156
+ };
157
+ while (i < pattern.length) {
158
+ const c = pattern[i];
159
+ const two = pattern.slice(i, i + 2);
160
+ if (classNames[two]) {
161
+ out.push(`Match ${classNames[two]}.`);
162
+ i += 2;
163
+ continue;
164
+ }
165
+ if (c === '\\') {
166
+ out.push(`Match the literal character "${pattern[i + 1] ?? ''}".`);
167
+ i += 2;
168
+ continue;
169
+ }
170
+ switch (c) {
171
+ case '^':
172
+ out.push('Anchor to the start of the line/string.');
173
+ break;
174
+ case '$':
175
+ out.push('Anchor to the end of the line/string.');
176
+ break;
177
+ case '.':
178
+ out.push('Match any single character (except newline).');
179
+ break;
180
+ case '*':
181
+ out.push('Repeat the previous item zero or more times.');
182
+ break;
183
+ case '+':
184
+ out.push('Repeat the previous item one or more times.');
185
+ break;
186
+ case '?':
187
+ out.push('Make the previous item optional (zero or one).');
188
+ break;
189
+ case '|':
190
+ out.push('OR — match the expression before or after this.');
191
+ break;
192
+ case '(': {
193
+ if (pattern.slice(i, i + 3) === '(?:') {
194
+ out.push('Start a non-capturing group.');
195
+ i += 2;
196
+ }
197
+ else {
198
+ out.push('Start a capturing group.');
199
+ }
200
+ break;
201
+ }
202
+ case ')':
203
+ out.push('End the group.');
204
+ break;
205
+ case '[': {
206
+ const end = pattern.indexOf(']', i);
207
+ const body = end > -1 ? pattern.slice(i + 1, end) : pattern.slice(i + 1);
208
+ const neg = body.startsWith('^');
209
+ out.push(`Match ${neg ? 'any character NOT' : 'any one character'} in the set [${neg ? body.slice(1) : body}].`);
210
+ i = end > -1 ? end : pattern.length;
211
+ break;
212
+ }
213
+ case '{': {
214
+ const end = pattern.indexOf('}', i);
215
+ const body = end > -1 ? pattern.slice(i + 1, end) : '';
216
+ out.push(`Repeat the previous item ${body} times.`);
217
+ i = end > -1 ? end : pattern.length;
218
+ break;
219
+ }
220
+ default:
221
+ out.push(`Match the literal character "${c}".`);
222
+ }
223
+ i++;
224
+ }
225
+ return out;
226
+ }
227
+ /** Run a JS regex against a test string and explain it, ported from the tool. */
228
+ export function regexTest(pattern, test, flags = 'g') {
229
+ let re;
230
+ try {
231
+ re = new RegExp(pattern, flags);
232
+ }
233
+ catch (e) {
234
+ return {
235
+ pattern,
236
+ flags,
237
+ valid: false,
238
+ error: e instanceof Error ? e.message : 'Invalid pattern',
239
+ count: 0,
240
+ matches: [],
241
+ explanation: pattern ? explainRegex(pattern) : [],
242
+ };
243
+ }
244
+ // Force global to enumerate every match (matchAll requires the g flag).
245
+ const g = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g');
246
+ const matches = [];
247
+ for (const m of test.matchAll(g)) {
248
+ matches.push({
249
+ match: m[0],
250
+ index: m.index ?? -1,
251
+ groups: m.slice(1).map((x) => x ?? ''),
252
+ namedGroups: m.groups ? { ...m.groups } : null,
253
+ });
254
+ if (m[0] === '')
255
+ break; // guard against zero-width infinite loop
256
+ }
257
+ return {
258
+ pattern,
259
+ flags,
260
+ valid: true,
261
+ error: null,
262
+ count: matches.length,
263
+ matches,
264
+ explanation: pattern ? explainRegex(pattern) : [],
265
+ };
266
+ }
267
+ // ── Word counter (ported from WordCounter.tsx) ───────────────────────────────
268
+ const WORDS_PER_MINUTE = 200;
269
+ function fmtTime(secs) {
270
+ if (secs < 60)
271
+ return `${secs}s`;
272
+ const m = Math.floor(secs / 60);
273
+ const s = secs % 60;
274
+ return s === 0 ? `${m} min` : `${m} min ${s}s`;
275
+ }
276
+ /** Word / character / sentence / paragraph stats, ported from the tool. */
277
+ export function wordCount(text) {
278
+ const trimmed = text.trim();
279
+ const words = trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
280
+ const charsWithSpaces = text.length;
281
+ const charsNoSpaces = text.replace(/\s/g, '').length;
282
+ const sentences = trimmed.length === 0
283
+ ? 0
284
+ : trimmed.split(/[.!?]+/).filter((s) => s.trim().length > 0).length;
285
+ const paragraphs = trimmed.length === 0
286
+ ? 0
287
+ : trimmed.split(/\n\s*\n/).filter((p) => p.trim().length > 0).length;
288
+ const lines = text.length === 0 ? 0 : text.split(/\n/).length;
289
+ const readingTimeSeconds = Math.ceil((words / WORDS_PER_MINUTE) * 60);
290
+ return {
291
+ words,
292
+ charsWithSpaces,
293
+ charsNoSpaces,
294
+ sentences,
295
+ paragraphs,
296
+ lines,
297
+ readingTimeSeconds,
298
+ readingTimeHuman: words === 0 ? '0s' : fmtTime(readingTimeSeconds),
299
+ };
300
+ }
301
+ // ── JWT decode (ported from JwtDecode.tsx) ───────────────────────────────────
302
+ const TIME_CLAIMS = ['iat', 'nbf', 'exp'];
303
+ function base64UrlDecodeToString(seg) {
304
+ const bytes = base64ToBytes(seg);
305
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
306
+ }
307
+ /** Decode (NOT verify) a JWT into header/payload + time claims. */
308
+ export function jwtDecode(token) {
309
+ const raw = token.trim();
310
+ const parts = raw.split('.');
311
+ if (parts.length < 2) {
312
+ throw new Error('Not a JWT: expected at least header.payload separated by dots.');
313
+ }
314
+ let header;
315
+ try {
316
+ header = JSON.parse(base64UrlDecodeToString(parts[0]));
317
+ }
318
+ catch {
319
+ throw new Error('Could not Base64url-decode the header as JSON.');
320
+ }
321
+ let payload;
322
+ try {
323
+ payload = JSON.parse(base64UrlDecodeToString(parts[1]));
324
+ }
325
+ catch {
326
+ throw new Error('Could not Base64url-decode the payload as JSON.');
327
+ }
328
+ const timeClaims = [];
329
+ let expired = null;
330
+ for (const claim of TIME_CLAIMS) {
331
+ const v = payload[claim];
332
+ if (typeof v === 'number' && Number.isFinite(v)) {
333
+ timeClaims.push({ claim, epoch: v, iso: new Date(v * 1000).toISOString() });
334
+ if (claim === 'exp')
335
+ expired = Date.now() > v * 1000;
336
+ }
337
+ }
338
+ return {
339
+ header,
340
+ payload,
341
+ signature: parts[2] ?? '',
342
+ timeClaims,
343
+ expired,
344
+ note: 'Signature is NOT verified. Never trust an unverified payload for authorization — verify server-side with the signing key.',
345
+ };
346
+ }
347
+ // ── CSS unit converter (ported from CssUnitConverter.tsx) ─────────────────────
348
+ const PX_PER_IN = 96;
349
+ const ABSOLUTE = [
350
+ { unit: 'px', pxPer: 1 },
351
+ { unit: 'in', pxPer: PX_PER_IN },
352
+ { unit: 'cm', pxPer: PX_PER_IN / 2.54 },
353
+ { unit: 'mm', pxPer: PX_PER_IN / 25.4 },
354
+ { unit: 'Q', pxPer: PX_PER_IN / 25.4 / 4 },
355
+ { unit: 'pt', pxPer: PX_PER_IN / 72 },
356
+ { unit: 'pc', pxPer: PX_PER_IN / 6 },
357
+ ];
358
+ export const CSS_UNITS = ['px', 'rem', 'em', 'pt', 'pc', 'in', 'cm', 'mm', 'Q'];
359
+ const round = (n) => Math.round(n * 1e6) / 1e6;
360
+ /** Convert a CSS length to every other unit, ported from the tool. */
361
+ export function cssUnitConvert(value, from, opts = {}) {
362
+ const root = opts.rootFontPx && Number.isFinite(opts.rootFontPx) ? opts.rootFontPx : 16;
363
+ const parent = opts.parentFontPx && Number.isFinite(opts.parentFontPx) ? opts.parentFontPx : 16;
364
+ // To px baseline first.
365
+ let px;
366
+ if (from === 'rem')
367
+ px = value * root;
368
+ else if (from === 'em')
369
+ px = value * parent;
370
+ else {
371
+ const abs = ABSOLUTE.find((a) => a.unit === from);
372
+ px = abs ? value * abs.pxPer : value;
373
+ }
374
+ const toUnit = (unit) => {
375
+ if (unit === 'rem')
376
+ return px / root;
377
+ if (unit === 'em')
378
+ return px / parent;
379
+ const abs = ABSOLUTE.find((a) => a.unit === unit);
380
+ return abs ? px / abs.pxPer : px;
381
+ };
382
+ const conversions = {};
383
+ for (const u of CSS_UNITS)
384
+ conversions[u] = round(toUnit(u));
385
+ return { value, from, rootFontPx: root, parentFontPx: parent, px: round(px), conversions };
386
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * GeraTools MCP Server (stdio)
3
+ *
4
+ * Exposes a handful of the real GeraTools developer utilities to AI agents over
5
+ * the Model Context Protocol. Every tool is a pure, deterministic function
6
+ * ported straight from the published GeraTools browser tools (or Node's
7
+ * crypto) — no backend, no network, no auth — so an agent (Claude, ChatGPT
8
+ * with tools, any MCP client) can base64-encode, hash, test a regex, count
9
+ * words, decode a JWT, or convert CSS units entirely offline.
10
+ *
11
+ * Product: GeraTools — https://geratools.com (a Gera Systems product)
12
+ */
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ export declare const server: McpServer;
15
+ export declare function main(): Promise<void>;
package/dist/server.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * GeraTools MCP Server (stdio)
3
+ *
4
+ * Exposes a handful of the real GeraTools developer utilities to AI agents over
5
+ * the Model Context Protocol. Every tool is a pure, deterministic function
6
+ * ported straight from the published GeraTools browser tools (or Node's
7
+ * crypto) — no backend, no network, no auth — so an agent (Claude, ChatGPT
8
+ * with tools, any MCP client) can base64-encode, hash, test a regex, count
9
+ * words, decode a JWT, or convert CSS units entirely offline.
10
+ *
11
+ * Product: GeraTools — https://geratools.com (a Gera Systems product)
12
+ */
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
+ import { z } from 'zod';
16
+ import { base64, hashText, SUPPORTED_HASHES, regexTest, wordCount, jwtDecode, cssUnitConvert, CSS_UNITS, } from './engine.js';
17
+ export const server = new McpServer({
18
+ name: 'geratools',
19
+ version: '1.0.0',
20
+ });
21
+ function asText(payload) {
22
+ return {
23
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
24
+ };
25
+ }
26
+ function asError(message) {
27
+ return {
28
+ isError: true,
29
+ content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
30
+ };
31
+ }
32
+ // ── Tool 1: base64 ────────────────────────────────────────────────────────────
33
+ server.registerTool('base64', {
34
+ title: 'Base64 encode / decode (UTF-8 safe)',
35
+ description: 'Encode text or hex bytes to Base64, or decode Base64 / Base64url back to text. Binary-safe via UTF-8 so emoji and non-ASCII round-trip correctly. Supports standard (+ / with = padding) and URL-safe (- _ no padding) variants. Decoding auto-restores padding and falls back to raw hex if the bytes are not valid UTF-8. Runs entirely offline — same logic as the GeraTools Base64 tool.',
36
+ inputSchema: {
37
+ input: z.string().describe('The text/hex to encode, or the Base64 string to decode.'),
38
+ mode: z.enum(['encode', 'decode']).describe('"encode" text→Base64, or "decode" Base64→text.'),
39
+ variant: z
40
+ .enum(['std', 'url'])
41
+ .optional()
42
+ .describe('Encode output: "std" (default) or "url" (URL-safe Base64url). Ignored when decoding.'),
43
+ input_kind: z
44
+ .enum(['text', 'hex'])
45
+ .optional()
46
+ .describe('When encoding, treat input as UTF-8 "text" (default) or "hex" bytes.'),
47
+ },
48
+ }, async ({ input, mode, variant, input_kind }) => {
49
+ try {
50
+ return asText(base64(input, mode, {
51
+ variant: variant,
52
+ inputKind: input_kind,
53
+ }));
54
+ }
55
+ catch (e) {
56
+ return asError(e instanceof Error ? e.message : 'Base64 operation failed.');
57
+ }
58
+ });
59
+ // ── Tool 2: hash ──────────────────────────────────────────────────────────────
60
+ server.registerTool('hash', {
61
+ title: 'Cryptographic hash (MD5 / SHA-1 / SHA-256 / SHA-384 / SHA-512)',
62
+ description: 'Compute a cryptographic digest of the input using Node crypto — the same algorithms a browser SubtleCrypto produces. Input can be utf8 (default), hex, or base64; output as hex (default) or base64. Deterministic and offline. Note: MD5 and SHA-1 are provided for checksums/legacy interop only, not for security.',
63
+ inputSchema: {
64
+ input: z.string().describe('The data to hash.'),
65
+ algorithm: z
66
+ .enum(SUPPORTED_HASHES)
67
+ .describe('Hash algorithm: md5, sha1, sha256, sha384, or sha512.'),
68
+ input_encoding: z
69
+ .enum(['utf8', 'hex', 'base64'])
70
+ .optional()
71
+ .describe('How to interpret the input bytes. Default utf8.'),
72
+ output_encoding: z
73
+ .enum(['hex', 'base64'])
74
+ .optional()
75
+ .describe('Digest output encoding. Default hex.'),
76
+ },
77
+ }, async ({ input, algorithm, input_encoding, output_encoding }) => {
78
+ try {
79
+ return asText(hashText(input, algorithm, {
80
+ inputEncoding: input_encoding,
81
+ outputEncoding: output_encoding,
82
+ }));
83
+ }
84
+ catch (e) {
85
+ return asError(e instanceof Error ? e.message : 'Hashing failed.');
86
+ }
87
+ });
88
+ // ── Tool 3: regex_test ────────────────────────────────────────────────────────
89
+ server.registerTool('regex_test', {
90
+ title: 'Test a regular expression against a string',
91
+ description: 'Run a JavaScript regular expression against a test string and return every match (with index, capture groups and named groups), the match count, and a plain-English, token-by-token explanation of the pattern. Invalid patterns return the engine error rather than throwing. Offline — same logic as the GeraTools Regex Tester.',
92
+ inputSchema: {
93
+ pattern: z.string().describe('The regular expression source (no surrounding slashes).'),
94
+ test_string: z.string().describe('The string to run the regex against.'),
95
+ flags: z
96
+ .string()
97
+ .optional()
98
+ .describe('Regex flags, e.g. "gi". Default "g". (g is forced internally for enumeration.)'),
99
+ },
100
+ }, async ({ pattern, test_string, flags }) => {
101
+ return asText(regexTest(pattern, test_string, flags ?? 'g'));
102
+ });
103
+ // ── Tool 4: word_count ────────────────────────────────────────────────────────
104
+ server.registerTool('word_count', {
105
+ title: 'Count words, characters, sentences and reading time',
106
+ description: 'Analyse a block of text: word count, characters (with and without spaces), sentence count, paragraph count, line count, and estimated reading time (200 wpm). Offline — same logic as the GeraTools Word Counter.',
107
+ inputSchema: {
108
+ text: z.string().describe('The text to analyse.'),
109
+ },
110
+ }, async ({ text }) => asText(wordCount(text)));
111
+ // ── Tool 5: jwt_decode ────────────────────────────────────────────────────────
112
+ server.registerTool('jwt_decode', {
113
+ title: 'Decode a JSON Web Token (no signature verification)',
114
+ description: 'Base64url-decode a JWT into its header and payload JSON, list the iat/nbf/exp time claims as ISO timestamps, and report whether the token is expired. Does NOT verify the signature — never trust an unverified payload for authorization. Offline — same logic as the GeraTools JWT Decoder.',
115
+ inputSchema: {
116
+ token: z.string().describe('The JWT, e.g. "header.payload.signature".'),
117
+ },
118
+ }, async ({ token }) => {
119
+ try {
120
+ return asText(jwtDecode(token));
121
+ }
122
+ catch (e) {
123
+ return asError(e instanceof Error ? e.message : 'Could not decode the token.');
124
+ }
125
+ });
126
+ // ── Tool 6: css_unit_convert ──────────────────────────────────────────────────
127
+ server.registerTool('css_unit_convert', {
128
+ title: 'Convert a CSS length between units',
129
+ description: 'Convert a CSS length value from one unit to every other CSS unit (px, rem, em, pt, pc, in, cm, mm, Q). Absolute units use 1in = 96px; rem depends on root font size and em on parent font size (both default 16px). Offline — same logic as the GeraTools CSS Unit Converter.',
130
+ inputSchema: {
131
+ value: z.number().describe('The numeric length value.'),
132
+ from: z.enum(CSS_UNITS).describe('The unit of the input value.'),
133
+ root_font_px: z
134
+ .number()
135
+ .optional()
136
+ .describe('Root font size in px, used for rem. Default 16.'),
137
+ parent_font_px: z
138
+ .number()
139
+ .optional()
140
+ .describe('Parent font size in px, used for em. Default 16.'),
141
+ },
142
+ }, async ({ value, from, root_font_px, parent_font_px }) => asText(cssUnitConvert(value, from, {
143
+ rootFontPx: root_font_px,
144
+ parentFontPx: parent_font_px,
145
+ })));
146
+ // ── Start ─────────────────────────────────────────────────────────────────────
147
+ export async function main() {
148
+ const transport = new StdioServerTransport();
149
+ await server.connect(transport);
150
+ // stderr only — stdout is the MCP transport.
151
+ console.error('GeraTools MCP server running on stdio (geratools v1.0.0)');
152
+ }
153
+ // Run when invoked directly as the built server (node dist/server.js).
154
+ // The bin/cli.js entry imports and calls main() itself, so we only auto-run
155
+ // for direct server.js invocation to avoid a double start.
156
+ const isMain = typeof process !== 'undefined' && process.argv[1] && /server\.js$/.test(process.argv[1]);
157
+ if (isMain) {
158
+ main().catch((err) => {
159
+ console.error('Fatal:', err);
160
+ process.exit(1);
161
+ });
162
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@gera-services/mcp-geratools",
3
+ "version": "1.0.0",
4
+ "description": "GeraTools MCP server — base64, cryptographic hashing, regex testing, word count, JWT decode and CSS unit conversion. Deterministic, offline, no auth. A Gera Systems product.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/server.js",
8
+ "types": "dist/server.d.ts",
9
+ "mcpName": "io.github.geraservicesuk/mcp-geratools",
10
+ "bin": {
11
+ "mcp-geratools": "bin/cli.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "bin",
16
+ "server.json",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc --noCheck",
21
+ "type-check": "tsc --noEmit",
22
+ "start": "node bin/cli.js",
23
+ "smoke": "node scripts/smoke.mjs"
24
+ },
25
+ "keywords": [
26
+ "mcp",
27
+ "model-context-protocol",
28
+ "base64",
29
+ "hash",
30
+ "sha256",
31
+ "regex",
32
+ "jwt",
33
+ "word-count",
34
+ "gera",
35
+ "geratools",
36
+ "developer-tools"
37
+ ],
38
+ "author": "Gera Systems Ltd",
39
+ "homepage": "https://geratools.com",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/geraservicesuk/globetura.git",
43
+ "directory": "packages/mcp-geratools"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.12.0",
47
+ "zod": "^3.23.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^20.12.0",
51
+ "typescript": "^5.4.0"
52
+ },
53
+ "engines": {
54
+ "node": ">=20"
55
+ }
56
+ }
package/server.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.geraservicesuk/mcp-geratools",
4
+ "description": "base64, hashing, regex, word count, JWT decode and CSS unit conversion. Deterministic, offline.",
5
+ "version": "1.0.0",
6
+ "repository": {
7
+ "url": "https://github.com/geraservicesuk/globetura",
8
+ "source": "github",
9
+ "subfolder": "packages/mcp-geratools"
10
+ },
11
+ "websiteUrl": "https://geratools.com",
12
+ "packages": [
13
+ {
14
+ "registryType": "npm",
15
+ "identifier": "@gera-services/mcp-geratools",
16
+ "version": "1.0.0",
17
+ "transport": {
18
+ "type": "stdio"
19
+ }
20
+ }
21
+ ]
22
+ }