digit-tools
README.md
# @digitable-lol/tools-core
Headless TypeScript port of the [it-tools](https://github.com/CorentinTh/it-tools) utility
collection: pure functions, a machine-readable catalog, and an MCP stdio server that exposes
all of it through **three** tools instead of ninety-four.
No Vue. No DOM. No browser APIs. Runs under Node, Bun, and as a single compiled binary.
- **95 tools** ported from **75** of the 86 upstream utilities
- **14 categories**, **114 worked examples** (all executed by the test suite)
- **220 tests passing**, `tsc --noEmit` clean
> **Origin and licence.** This is a derivative work of
> [it-tools](https://github.com/CorentinTh/it-tools) by Corentin Thomasset, which is
> published under the GNU General Public License version 3. Because this package copies and
> adapts that source, it inherits the same licence and is redistributed under
> **GPL-3.0-only** — see [LICENSE](LICENSE) and [NOTICE](NOTICE). Not affiliated with or
> endorsed by the upstream author.
---
## Why three MCP tools
Registering 95 MCP tools would push every input schema into the model's context on every
turn. This package routes in two cheap steps instead:
| MCP tool | Purpose | Payload |
| --- | --- | --- |
| `tools_categories` | Index of the 14 categories, one line each | ~536 tokens |
| `tools_list` | Full schemas + examples for **one** category | 1-17 tools |
| `tools_execute` | Run a tool by `tool_id` with `args` | — |
Results are returned both as `structuredContent` and as a pretty-printed JSON text block, so
clients that read either channel work unchanged.
The design target is a 0.6B router model, which is why descriptions are short and
single-purpose, schemas are flat (never deeper than two levels of properties), and `oneOf` /
`anyOf` never appear in an input schema. A test enforces all three properties.
---
## Quick start
```bash
npm install
npm test # 220 tests
npm run typecheck
npm run build:binary # -> dist/digit-tools-mcp
npm run smoke:binary # replays all 114 catalog examples through the binary
```
Run the server straight from source:
```bash
npm run mcp # node --experimental-strip-types src/mcp.ts
```
Use it as a library:
```ts
import { executeTool, getCategoryIndex, getToolsInCategory } from '@digitable-lol/tools-core';
getCategoryIndex(); // step 1: which category?
getToolsInCategory('crypto'); // step 2: which tool, and what arguments?
await executeTool('hash_text', { text: 'hello', algorithm: 'SHA256' });
// { ok: true, result: { algorithm: 'SHA256', encoding: 'Hex', hash: '2cf24dba…' } }
await executeTool('hash_text', { algorithm: 'SHA256' });
// { ok: false, code: 'invalid_args', error: "must have required property 'text'" }
```
`executeTool` never throws. Every outcome is `{ok: true, result}` or
`{ok: false, error, code}`, where `code` is one of `unknown_tool`, `invalid_args`,
`execution_error`, `timeout`. Arguments are validated against the tool's `input_schema` with
ajv (`coerceTypes` and `useDefaults` on, since small models routinely emit `"2024"` where a
number is wanted).
---
## Connecting an MCP client
Compiled binary — no runtime required on the host:
```json
{
"mcpServers": {
"digit-tools": {
"command": "/absolute/path/to/tools-core/dist/digit-tools-mcp"
}
}
}
```
From source, using Node's built-in TypeScript stripping:
```json
{
"mcpServers": {
"digit-tools": {
"command": "node",
"args": [
"--experimental-strip-types",
"/absolute/path/to/tools-core/src/mcp.ts"
]
}
}
}
```
### Binary size and startup
| Metric | Value |
| --- | --- |
| Binary size | 111,683,904 bytes (107 MiB) |
| Startup (spawn → `initialize` response), median | **542 ms** |
| Startup, min / p90 | 390 ms / 641 ms |
| Same, running from source under Node | ~3,900 ms median |
Measured with `npm run bench:startup` (12 runs, first two discarded) on Linux 6.8, Node
v24.18, Bun 1.3.12, at a 1-minute load average of ~10 on 8 cores.
Caveat on these numbers: this machine carries other workloads. A re-run at load average ~48
gave min 3,711 ms / median 7,794 ms for the same binary — roughly 14x worse purely from CPU
contention. Re-measure on an idle host before treating 542 ms as the figure; the shape of
the result (binary ≈ 7x faster than Node-from-source) held at both load levels.
Startup was 1,214 ms before the heaviest payloads — mathjs, the OUI table, markdown-it,
libphonenumber, sql-formatter, node-forge, qrcode, figlet, the emoji dataset — were moved behind dynamic
`import()` calls inside the tools that need them. The first call to those tools pays a
one-time load cost; everything else starts fast.
---
## The catalog
`src/catalog.ts` is the key artifact. Each entry:
```ts
{
id: 'hash_text',
name_ru: 'Хеш текста',
name_en: 'Hash text',
category: 'crypto',
description_ru: 'Считает криптографический хеш строки выбранным алгоритмом.',
deterministic: true,
input_schema: { /* flat JSON Schema */ },
output_schema: { /* flat JSON Schema */ },
examples: [{ input: {...}, output: {...} }],
run: hashText,
}
```
API:
- `getCategoryIndex()` — the 14 categories with one-line descriptions and tool counts
(1,875 chars ≈ 536 tokens)
- `getToolsInCategory(cat)` — full schemas for one category, implementations stripped
- `getToolSchema(id)`, `getFullCatalog()`, `getToolIds()`
- `npm run catalog:dump` writes the whole thing as JSON (~184 KB)
Entries live in `src/catalog/*.ts` grouped by family; `src/catalog.ts` aggregates them and
exposes the routing API.
### Categories
| id | Russian name | Tools |
| --- | --- | ---: |
| `crypto` | Криптография | 9 |
| `generators` | Генераторы | 9 |
| `auth` | Аутентификация | 7 |
| `encoding` | Кодирование | 12 |
| `converter` | Конвертеры форматов | 17 |
| `text` | Работа с текстом | 8 |
| `web` | Веб | 10 |
| `network` | Сети | 5 |
| `development` | Разработка | 5 |
| `data` | Разбор данных | 4 |
| `datetime` | Дата и время | 1 |
| `math` | Математика | 3 |
| `measurement` | Измерения | 2 |
| `images` | Изображения | 3 |
Ten come straight from the upstream registry; `generators`, `auth`, `encoding` and
`datetime` were split out of it-tools' oversized `Converter` and `Web` buckets so no
category is large enough to blow the router's context.
### Examples are the eval set
Every tool has at least one example, and `test/catalog.test.ts` executes all 114 through
`executeTool`. For the 80 deterministic tools the output is compared with a deep equality
check. For the 15 non-deterministic ones the example carries `match: 'shape'` and only the
key set and value kinds are verified — the values genuinely change between runs.
`npm run smoke:binary` replays the same 114 examples through the **compiled binary** over
real stdio. This is what caught the figlet font-loading bug described below; a passing unit
suite would not have.
---
## What was ported
**Group A — pure deterministic functions (62 upstream utilities).** Hashes, HMAC, codecs,
format converters, parsers, formatters, calculators. Logic carried over from the upstream
`.service.ts` / `.models.ts` where one existed, or lifted out of the `<script setup>` block
where it did not. Cryptographic code (bcrypt, BIP39, JWT, crypto-js ciphers, OTP) was moved
verbatim and still leans on the same npm packages at the same major versions as it-tools.
**Group C — random or clock-dependent (13 upstream utilities, 15 tool ids).** Ported and
flagged `deterministic: false`:
`bcrypt_hash`, `encrypt_text`, `rsa_keypair_generate`, `token_generate`, `uuid_generate`,
`ulid_generate`, `bip39_generate`, `lorem_ipsum_generate`, `random_port_generate`,
`mac_address_generate`, `otp_generate_totp`, `otp_verify_totp`, `otp_secret_generate`,
`ipv6_ula_generate`, `eta_calculate`
Where it was cheap, randomness was made reproducible. `src/utils.ts` provides a seeded
mulberry32 PRNG, and an optional integer `seed` makes `token_generate`, `uuid_generate`,
`ulid_generate`, `lorem_ipsum_generate`, `random_port_generate`, `mac_address_generate` and
`otp_secret_generate` repeatable. Clock-driven tools take an explicit time instead:
`otp_generate_totp`/`otp_verify_totp` accept `now`, `ipv6_ula_generate` accepts `timestamp`,
`eta_calculate` accepts `startedAtMs`.
Not seedable, deliberately: `bcrypt_hash` and `encrypt_text` draw their salt from the
underlying library, and `rsa_keypair_generate` from node-forge. Forcing a seed there would
mean reimplementing cryptographic primitives, which is exactly the kind of change that
introduces silent security bugs.
Three tools are deterministic despite living next to random ones, and are marked as such:
`bcrypt_compare`, `decrypt_text`, `bip39_from_entropy` / `bip39_to_entropy`.
### What was NOT ported (Group B — 11 utilities)
| Utility | Reason |
| --- | --- |
| `base64-file-converter` | Needs a browser `File`/`FileReader` upload |
| `camera-recorder` | `MediaRecorder` + camera permissions |
| `chronometer` | A live wall-clock stopwatch; no meaning in a request/response tool |
| `device-information` | Reads `navigator`, `screen`, `window` |
| `keycode-info` | Reads live DOM keyboard events |
| `pdf-signature-checker` | Needs an uploaded PDF binary |
| `html-wysiwyg-editor` | TipTap editor UI; no headless logic |
| `text-diff` | Pure Monaco diff-editor widget; the `.vue` file contains no logic at all |
| `git-memo` | Static cheatsheet, not a computation |
| `regex-memo` | Static cheatsheet, not a computation |
| `meta-tag-generator` | Skipped: depends on `@it-tools/oggen`, and the value is a form UI rather than a function |
The last three are honest omissions rather than technical impossibilities — a cheatsheet
lookup tool could be built, it just would not be a port of anything.
---
## Deliberate deviations from upstream
Ten behaviour changes, made on purpose, each because the browser original was wrong for a
headless caller. Everything not on this list is a straight port — see [NOTICE](NOTICE) for
the structural changes (Vue removed, catalog / execution / MCP layers added) that sit on top
of these.
1. **`xml_format` emits LF, not CRLF.** `xml-formatter` defaults to `\r\n`.
2. **`temperature_convert` rounds to 10 decimals.** The scale factors are irrational in
binary, so 100 °C → °F produced `211.99999999999994`. Rounding removes the noise without
touching any precision a temperature reading carries.
3. **`benchmark_stats` takes one series, not many.** Upstream compared several suites at
once, which forces an array-of-objects-holding-arrays input — three levels of schema
nesting. One series per call keeps the schema flat.
4. **`chmod_calculate` takes a flat string.** Upstream drove nine checkboxes; here `"755"`
or `"rwxr-xr-x"` works, in either direction.
5. **`json_diff` returns a flat change list.** Upstream produced a nested tree for its tree
widget; a list of `{path, status, oldValue, value}` is far easier for a small model.
6. **Errors are thrown, not swallowed.** Upstream wrapped most tools in
`withDefaultOnError(..., '')` and rendered an empty string. Here the throw propagates to
`executeTool`, which turns it into `{ok: false, code: 'execution_error'}`. Affects
`xml_format`, `token_generate` (empty alphabet) and the format converters.
7. **`eta_calculate` formats durations without date-fns locales**, so output is stable.
8. **`jwt_parse` renders `exp`/`iat`/`nbf` as ISO 8601**, not a machine-local locale string.
9. **QR codes render as SVG**, not a canvas — same `qrcode` package, headless renderer.
10. **`emoji-picker` became `emoji_search`.** Upstream is a scrollable grid with a fuse.js
search box; headless, only the search means anything. Fuzzy matching is replaced by
exact / prefix / substring tiers over name, slug, keywords, group, code points and the
emoji itself, so a query always returns the same list in the same order. With no `query`
the tool returns the first `limit` emoji of the dataset (optionally of one `group`).
Everything else is a straight port. Where upstream had a `.service.ts`, the algorithm is
unchanged.
---
## Tests
```
test/catalog.test.ts 124 tests catalog integrity + all 114 examples via executeTool
test/ported.test.ts 60 tests upstream unit tests, imports adapted
test/tools.test.ts 18 tests mac_address_lookup and emoji_search, incl. embedded data
test/execute.test.ts 11 tests validation, defaults, coercion, timeouts, error mapping
test/mcp.test.ts 7 tests the three MCP tools over an in-memory transport
──────────────────────────────────
220 passing
```
`test/ported.test.ts` carries over every upstream `*.service.test.ts` / `*.models.test.ts`
that survived the port: chmod, hash-text, integer-base-converter, ipv4-address-converter,
ipv4-range-expander, json-diff, json-to-csv, json-viewer, list-converter,
mac-address-generator, numeronym, OTP, password-strength, regex-tester, roman-numerals,
safelink, string-obfuscator, text-statistics, text-to-binary, text-to-unicode,
token-generator, xml-formatter, color-converter, date-time-converter. The two assertions
that changed are commented in place, both consequences of deviation #6 above.
Upstream tests that were dropped: `chronometer.service.test.ts` (tool not ported).
---
## Known limitations
- **The execution timeout is not preemptive.** JavaScript cannot interrupt synchronous
CPU-bound work. `withTimeout` fires at the next `await` boundary, which in practice covers
the lazily-loaded tools; a runaway regex inside `regex_test` would still block the process.
Fixing this properly needs a worker thread. Documented on `withTimeout` in
`src/execute.ts` and pinned by a test.
- **`ascii_art_generate` ships 15 fonts, not figlet's 328.** figlet reads `.flf` files from
disk at runtime, which `bun build --compile` does not bundle — the binary crashed with
`ENOENT: /$bunfs/fonts/Standard.flf`. A curated set is embedded as data by
`scripts/generate-figlet-fonts.mjs` (~300 KB) and loaded lazily. Add names to that script
and to `src/tools/figlet-font-names.ts` to include more.
- **The OUI vendor table is embedded, not read from `oui-data`.** Same trap as the fonts:
the package is a bare `index.json` that `require` resolved from node_modules, so the
compiled binary failed with `Cannot find package 'oui-data' from '/$bunfs/root/…'` whenever
it ran outside this checkout. All 39,227 entries are inlined by
`scripts/generate-oui-data.mjs` into `src/tools/oui-data.ts` (~4 MB) and loaded through a
lazy `import()`. Run `npm run generate:oui` after bumping the dev dependency to refresh it.
- **`emoji_search` ships a snapshot of the CLDR emoji data.** 1,870 emoji with names, slugs,
groups and keywords are inlined in `src/tools/emoji-data.ts` (~260 KB), taken from
`unicode-emoji-json` 0.4.0 and `emojilib` 3.0.10 — the datasets upstream loads in the
browser. Refreshing them means regenerating that file.
- **`json_to_toml` / `yaml_to_toml` write `8_080`, not `8080`.** `@iarna/toml` uses
underscore digit separators for larger integers. This is the library's canonical output,
left as-is.
- **`iban_validate` reports a generic checksum error.** ibantools v4 exposes its detailed
error-code list through an overload that `extractIBAN` does not surface, so the `errors`
array is coarser than upstream's.
- **The binary is 107 MiB.** Bun's compiled output embeds the whole runtime. The embedded
OUI table (~4 MB), the figlet fonts (~300 KB) and the emoji dataset (~260 KB) add to it,
but the runtime dominates.
- `iarna-toml-esm` (used upstream) is unloadable under plain Node ESM, so this package uses
`@iarna/toml` v3 — the same code, same major version, packaged for CommonJS.
## Layout
```
src/
catalog.ts aggregation + getCategoryIndex / getToolsInCategory
catalog/*.ts catalog entries grouped by family
execute.ts executeTool: ajv validation, timeout, structured results
mcp.ts binary entry point
mcp-server.ts the three MCP tools
tools/*.ts the ported implementations
utils.ts base64, seeded PRNG, shared helpers
interop.ts dual-build (CJS/ESM) default-export unwrapping
node-require-shim.ts global `require` for js-sha256's eval probe
scripts/ font/OUI generation, catalog dump, binary smoke test, benchmark
test/ the four suites above
```
`src/interop.ts` and `src/node-require-shim.ts` exist because several dependencies ship both
a CommonJS and an ESM build. Node resolves one, Bun's bundler resolves the other, and a
plain default import works in exactly one of them. The namespace-import-plus-unwrap pattern
is what lets identical source run under `node --experimental-strip-types`, vitest, and
`bun build --compile`.
## License
**GPL-3.0-only**, inherited — not chosen — from
[it-tools](https://github.com/CorentinTh/it-tools) by Corentin Thomasset. This package
copies and adapts it-tools source code, which makes it a derivative work, so the GNU General
Public License version 3 covers it as a whole. Upstream states "GNU GPLv3" and ships the
plain GPL-3.0 text with no "or any later version" grant, so version 3 is the version that
applies here too.
The full licence text is in [LICENSE](LICENSE). Provenance, the list of changes, and the
third-party data notices are in [NOTICE](NOTICE).
Practical consequence: if you hand someone the compiled binary (`dist/digit-tools-mcp`),
GPL-3.0 sections 4-6 require you to give them the corresponding source under the same
licence. That obligation is why this repository is published rather than kept private — the
binary is distributable precisely because its source is here.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues