toon

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

6posts · 30d
Nov 30 posts
  1. I stopped sending JSON keys to my LLM 100 times. Here's what it saved (and where it didn't) --- title: I stopped sending JSON keys to my LLM 100 times. Here's what it saved (and where it didn't) published: true description: LWON is JSON with the keys written once. Benchmarks against JSON and TOON, accuracy evals on Claude, and an MCP proxy that applies it to any server. tags: ai, llm, json, opensource cover_image: --- Look at the next tool result your agent gets back from an API. Something like this: [{"id":"A-1","status":"open","assignee":"ann","labels":["bug"]}, {"id":"A-2","status":"closed","assignee":"bob","labels":[]}, ...] Now imagine 100 rows. The words id, status, assignee and labels are sent 100 times each, with quotes around every one. The model doesn't need them 100 times. It needs them once. That's the whole idea behind LWON (Lightweight Object Notation): JSON with the keys written once. A header names the fields; the body holds only the values, by position. What it looks like {[orders[id customer[name city] [items[sku qty price]]]] page} [[ [A-1 [Ann Istanbul] [[SKU1 2 9.99] [SKU2 1 19.5]]] [A-2 [Bob Izmir] []] ] 1] That is exactly this JSON: {"orders":[{"id":"A-1","customer":{"name":"Ann","city":"Istanbul"},"items":[{"sku":"SKU1","qty":2,"price":9.99},{"sku":"SKU2","qty":1,"price":19.5}]},{"id":"A-2","customer":{"name":"Bob","city":"Izmir"},"items":[]}],"page":1} Line 1 is the shape. Every row after it is just values. Nested objects and arrays of objects get their own fields in the header, so it stays compact even when the data isn't a flat table. The hard requirement I set for myself: any JSON value must convert to LWON and back without loss. Escapes, null versus a missing key, objects with different fields, weird keys, primitive roots. The test suite runs a language-neutral conformance set plus 3000 fuzzed round trips. "Isn't this just TOON / CSV?" Fair question. TOON has the same goal and is very good on flat tables. CSV is fine until your data has nesting. The difference shows up on the data agents actually see: API responses with nested objects, trees, and optional fields. Here are Claude token counts (via the count_tokens API) on six datasets: | Dataset | JSON (minified) | TOON | LWON | |---|--:|--:|--:| | Flat table, 100 rows | 4295 | 2510 | 2503 | | Nested orders | 8259 | 7435 | 4808 | | GitHub-style issues | 4378 | 4414 | 2760 | | Recursive menu | 562 | 578 | 362 | | Logs with optional fields | 4393 | 4472 | 3452 | | Search tool result | 2930 | 2200 | 2298 | On flat tables LWON and TOON are basically tied, and TOON wins the search result. On nested and recursive data, LWON is 21–42% smaller than minified JSON, while table formats fall back to layouts close to JSON's size. Fewer tokens is worthless if the model gets it wrong This was the part I cared about most. A format that saves 40% and makes the model misread a field is a net loss. So I wrote an eval: 44 questions about the benchmark data (lookups, counts, nested fields, trees, absent versus null), asked in JSON, TOON and LWON, to Claude Haiku, Sonnet and Opus. Every model got every question right in all three formats. Then I made it harder: 150 sparse log events where most fields are missing in most rows. Here LWON missed 11 of 176 answers and JSON missed 9. All misses in both formats were counts over many rows off by one to three, or a sum. I want to be upfront about that: on very sparse data, LWON is slightly behind JSON on accuracy, not ahead. The thing I got wrong in 0.x Input tokens are not the whole bill. Models think about the data, and output tokens cost more than input. In LWON 0.x, every key was written by position, so sparse rows looked like [x ~ ~ ~ ~ ~ 3 ~ ~ ~]. The models had to count positions through all those ~ placeholders, reasoned much longer, and on the sparse data a question cost up to 3× as much as with JSON. Smaller input, bigger bill. The fix in 1.0: rare keys are written by name in a trailing * slot. Common fields stay positional, rare ones stay readable. Counting everything (input, thinking, output), a question now costs: - 21–25% less than JSON on regular data sent once (2–15% less when cached) - 9–11% less on sparse data sent once, but 6–9% more when cached So: measure on your data. That's why the repo ships the eval scripts, not just the numbers. Try it on your own JSON The quickest way to see if LWON helps you is the playground. Paste any JSON, or load one of the benchmark datasets, and it converts both ways and shows the token count next to minified JSON. Everything runs in your browser; your data isn't sent anywhere. Tokens there are counted with the o200k tokenizer, so the exact numbers differ a little from Claude's, but the ratio is close. Using it npm install @lwon/core import { encode, decode, PRIMER } from '@lwon/core'; const text = encode(data); // any JSON value → LWON const back = decode(text); // LWON → the same JSON value // Models don't know LWON yet, so send a short primer (~330 tokens) once. const prompt = `${PRIMER}\n\n${text}`; One rule: convert before the data reaches the model, in your tool or API layer. Asking the model to convert saves nothing. There's also a CLI if you want to check your own data first: npx @lwon/core stats data.json # JSON vs LWON size Zero-code: an MCP proxy Most of the JSON my agents see comes from MCP tool results, so I wrote @lwon/mcp-proxy. Put it in front of any stdio MCP server and large JSON tool results reach the model as LWON. The server doesn't change. claude mcp add github -- npx -y @lwon/mcp-proxy -- npx -y @modelcontextprotocol/server-github It only rewrites a result when it's JSON, at least 2000 characters, and at least 10% shorter as LWON. Errors, plain text, images and structuredContent pass through untouched. The primer goes into the server's instructions once per session, not once per result. Status and what I'd love feedback on LWON is 1.0 beta: the spec, a dependency-free TypeScript library, a conformance suite, and the MCP proxy, under Apache-2.0. Python and Go ports are next. I'd really like to hear: - Your data. Paste a real tool result into the playground and tell me the number, especially if LWON loses. - Other models. My evals are Claude-only so far. If you test GPT or Gemini, I'll add the results. - The spec. If you find a JSON value that doesn't round-trip, that's a bug I want. - Playground: ahmetzencirli.github.io - Website and spec: ahmetzencirli.github.io - GitHub: ahmetzencirli/lwon Thanks for reading!

    8 comments
  2. Build a BPE Tokenizer in 80 Lines of TypeScript and Watch JSON Eat Your Tokens - Book: AI That Answers - The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- The maintainers of the TOON format ran three flat datasets through OpenAI's o200k_base encoding and counted 164,643 tokens for the pretty-printed JSON and 104,908 for the same data minified. Nothing was dropped. The minified version holds every key and every value. It stops paying for indentation and line breaks, and that alone takes about 36% off the count. Tokens are the unit your LLM bill is written in. Anthropic's pricing table, for one, quotes input and output separately, per million tokens. Yet most developers who pay that bill have never watched a string turn into tokens. You can build the machine that does it in 80 lines of TypeScript and train it on your own JSON. What happens to your prompt before the model sees it OpenAI's tokenizers use byte-pair encoding. BPE began as a compression algorithm, and Sennrich, Haddow and Birch adapted it to split words into subword units for neural machine translation (ACL 2016). The tiktoken README says the encoding is reversible and lossless and works on any text, even text the tokenizer never saw in training. On average, a token covers about 4 bytes. Encoding runs in three steps. 1. A regex cuts the text into chunks, so letters, digits, punctuation and whitespace end up in separate runs. Karpathy's minbpe credits this to the GPT-2 paper and notes it "ensures that no merges will happen across category boundaries." 2. Each chunk becomes its UTF-8 bytes. The starting vocabulary is the 256 possible byte values, so nothing is ever out of vocabulary. 3. Learned merges glue adjacent byte pairs into bigger tokens, in the order they were learned. Training is where the merges come from. Count every adjacent pair across the corpus, take the pair with the highest count, give it the next free id (256, then 257, and so on), replace it everywhere, repeat. The minbpe README walks through aaabdaaabac: three merges (aa, then ab, then the pair of those two new tokens) turn 11 bytes into five tokens, [258, 100, 258, 97, 99]. !Rows of small square byte tiles fusing pairwise into larger tiles, layer by layer Byte-pair encoding: start from single bytes, then fuse the most frequent neighbours, one merge at a time. The tokenizer in 80 lines This is the whole trainer and encoder. It uses GPT-2's pre-split pattern, minus the contraction rules ('s, 'll, and friends), copied from minbpe's regex.py. // bpe.ts: a toy byte-level BPE trainer and encoder type Pair = [number, number]; // GPT-2's pre-split pattern, minus its contraction rules const SPLIT = / ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu; const utf8 = new TextEncoder(); const text8 = new TextDecoder(); const key = (a: number, b: number) => `${a},${b}`; const show = (b: Uint8Array) => JSON.stringify(text8.decode(b)); const chunksOf = (text: string) => (text.match(SPLIT) ?? []).map((c) => Array.from(utf8.encode(c))); function countPairs(ids: number[], counts: Map<string, number>) { for (let i = 0; i < ids.length - 1; i++) { const k = key(ids[i], ids[i + 1]); counts.set(k, (counts.get(k) ?? 0) + 1); } } function merge(ids: number[], [a, b]: Pair, id: number) { const out: number[] = []; for (let i = 0; i < ids.length; i++) { if (ids[i] === a && ids[i + 1] === b) { out.push(id); i++; // the pair's second half is consumed too } else out.push(ids[i]); } return out; } export function train(text: string, numMerges: number) { let chunks = chunksOf(text); const merges = new Map<string, number>(); const vocab = new Map<number, Uint8Array>(); for (let b = 0; b < 256; b++) vocab.set(b, Uint8Array.of(b)); for (let m = 0; m < numMerges; m++) { const counts = new Map<string, number>(); for (const ids of chunks) countPairs(ids, counts); let best = ""; let bestCount = 1; // a pair seen once earns no token for (const [k, c] of counts) { if (c > bestCount) { best = k; bestCount = c; } } if (!best) break; const [a, b] = best.split(",").map(Number) as Pair; const id = 256 + m; chunks = chunks.map((ids) => merge(ids, [a, b], id)); merges.set(best, id); const l = vocab.get(a)!, r = vocab.get(b)!; const bytes = new Uint8Array([...l, ...r]); vocab.set(id, bytes); console.log(`#${m}`, show(l), "+", show(r), "->", show(bytes), `${bestCount}x`); } return { merges, vocab }; } export function encode(text: string, merges: Map<string, number>) { const out: number[] = []; for (let ids of chunksOf(text)) { while (ids.length > 1) { // earliest-learned merge first, the order training used let pair: Pair | null = null; let rank = Infinity; for (let i = 0; i < ids.length - 1; i++) { const r = merges.get(key(ids[i], ids[i + 1])); if (r !== undefined && r < rank) { rank = r; pair = [ids[i], ids[i + 1]]; } } if (!pair) break; ids = merge(ids, pair, rank); } out.push(...ids); } return out; } Pairs are keyed as "a,b" strings because a Map compares array keys by reference, so [1, 2] would never find [1, 2]. Ties go to the pair seen first, since Map iterates in insertion order and the comparison is strict. Training works chunk by chunk, so a merge can never span a letter and a digit, or a word and the punctuation after it, which matches the real thing. And encode always applies the lowest-ranked merge available, the same rule minbpe's _encode_chunk uses: replay the merges in the order training learned them. The show helper decodes a token's bytes for printing. When a token holds only part of a multi-byte character, TextDecoder prints �, because the token boundary falls inside the character. The Japanese sample further down does the same. Train it on JSON and read the merges Feed it the kind of text your app actually sends: an array of records, pretty-printed. // demo.ts import { train, encode } from "./bpe"; const records = Array.from({ length: 40 }, (_, i) => ({ customer_id: 1000 + i, status: i % 4 ? "active" : "suspended", country: ["DE", "BR", "JP", "US"][i % 4], })); const pretty = JSON.stringify(records, null, 2); const { merges } = train(pretty, 40); const one = records[0]; const asPretty = encode(JSON.stringify(one, null, 2), merges); const asMin = encode(JSON.stringify(one), merges); console.log({ pretty: asPretty.length, minified: asMin.length }); Run it with tsx: npx tsx demo.ts. Each log line is one merge, with the two parts, the new token, and how often the pair appeared. Read all 40 and sort them in your head: how many are fragments of the keys customer_id, status and country, how many are values and digits, and how many are quotes, colons and runs of spaces? On a corpus made of nothing but JSON, every merge has to come from one of those. The keys repeat in every single record, and that repetition is what earns them merges. A production tokenizer learns its merges from a huge general corpus instead, so common English words have a good chance of getting a token of their own (the next script shows you whether customer did). Your acctBalanceAdjFlagV2 pays for every piece. Then try the experiments the script makes cheap. Train on JSON.stringify(records) instead of the pretty version and compare the merge log. Rename customer_id to cid and see how many merges it frees up. Compare the two counts in the last line, and remember that this toy only knows 40 merges, so the ratio between them tells you more than either number. Ask a real encoder: o200k_base Your toy has 40 merges. o200k_base has a vocabulary of about 200,000 tokens, roughly double GPT-4's ~100,000, according to Simon Willison's notes from the GPT-4o launch. tiktoken's model table maps gpt-4o, gpt-4.1, o3, o4-mini and gpt-5 to it. js-tiktoken loads it in Node; the lite import pulls in only the one rank file you need. // compare.ts import { Tiktoken } from "js-tiktoken/lite"; import o200k_base from "js-tiktoken/ranks/o200k_base"; const enc = new Tiktoken(o200k_base); const pieces = (s: string) => enc.encode(s).map((id) => enc.decode([id])); const samples = [ '{\n "customer_id": 1000\n}', '{"customer_id":1000}', "customerShippingAddressLine2", "1730505600000", "Your order has been cancelled.", "ご注文はキャンセルされました。", ]; for (const s of samples) { const p = pieces(s); console.log(p.length, JSON.stringify(p)); } You can predict part of what it prints by reading the o200k pre-split regex in tiktoken's openai_public.py. Every chunk the regex produces costs at least one token, so the regex sets a floor. - camelCase splits at every capital. The letter rule matches a run of uppercase letters (possibly none) followed by lowercase, so customerShippingAddressLine2 cuts into customer, Shipping, Address, Line and 2 before BPE starts. Five chunks, at least five tokens. - Digits go in groups of three. The number rule is \p{N}{1,3}. A 13-digit millisecond timestamp is five chunks, so at least five tokens, every time it appears in a log line you paste. - Whitespace is its own chunk. Indentation runs, minus their last space, are cut out separately from the keys they sit in front of. One small quirk: the space after "customer_id": in the pretty version cannot attach to a following number, because the digit rule (\p{N}{1,3}) takes no leading character and the punctuation rule's optional space needs a symbol after it. It becomes a chunk on its own. Before a string value it rides along with the opening quote instead. - Non-English text gets fewer merges. Byte-level BPE can encode anything, but the merges go to whatever was common in training data. Petrov et al. (NeurIPS 2023) found the same text translated into different languages could differ in token length by up to 15 times on the tokenizers they studied. OpenAI's larger vocabulary cut non-English counts: its launch figures, as quoted in Willison's notes, put Gujarati at 4.4x fewer tokens and Japanese at 1.4x fewer than the previous tokenizer. When a token boundary still lands inside a Japanese character, pieces prints � for those fragments. !A long accordion of curly braces and indentation blocks next to a compact stack of the same blocks Same data, same keys and values; only the whitespace and repetition change, and that is where the tokens go. One payload, three shapes Now measure the thing you actually send. This script builds one payload and counts it as pretty JSON, minified JSON and TOON, a tabular encoding of the JSON data model that declares the keys of a uniform array once (@toon-format/toon on npm). // shapes.ts import { Tiktoken } from "js-tiktoken/lite"; import o200k_base from "js-tiktoken/ranks/o200k_base"; import { encode as toToon } from "@toon-format/toon"; const enc = new Tiktoken(o200k_base); const orders = Array.from({ length: 50 }, (_, i) => ({ orderId: 5000 + i, customerId: 70 + (i % 9), status: i % 3 ? "shipped" : "pending", totalCents: 1999 + i * 37, })); const payload = { orders }; const shapes: Record<string, string> = { pretty: JSON.stringify(payload, null, 2), minified: JSON.stringify(payload), toon: toToon(payload), }; for (const [name, text] of Object.entries(shapes)) { console.log(name.padEnd(9), enc.encode(text).length); } Swap payload for a real request body from your logs and you have your own numbers. I can't give you numbers for your data, so here are published ones for theirs. TOON's benchmark, counted with o200k_base: | Track | Pretty JSON | Minified JSON | TOON | |---|---|---|---| | Flat data, 3 datasets | 164,643 | 104,908 | 68,030 | | Mixed structure, 5 datasets | 393,637 | 260,451 | 264,734 | On flat, uniform records TOON came in 58.7% under pretty JSON and 35.2% under minified. On mixed and nested data the picture flips: minified JSON beat TOON by 1.6% overall, and the README says plainly that "for deeply nested or non-uniform data, JSON may be more efficient." It also notes that absolute counts are tokenizer-specific, since other providers tokenize differently, and that you have to measure latency yourself. Fewer tokens is also not the same as better answers, so run your evals on the new shape before you ship it. One win held on both tracks: minifying the JSON cut about a third of the tokens, so do that to anything a program produced before it goes into a prompt. Dropping keys the model does not need helps whatever the shape. Save the tabular format for long arrays of identical objects, where TOON won, and keep JSON for nested data, where it lost. Then put shapes.ts next to your prompt builder and check the count every time someone adds a field. --- If this was useful Token cost is the first bill an LLM app runs up, and it is one of the threads in AI That Answers, the first book of my AI in TypeScript series. It covers your first LLM app in TypeScript end to end: prompts, structured output, and keeping an eye on what each call costs before the invoice does it for you. ![AI That Answers — AI in TypeScript](amazon.com) The other four books go from retrieval and tool calling to agents in production: the whole AI in TypeScript series is here.

    3 reactions · 4 comments
  3. YouTube · · YouTube description · AI Húp

    Top 3 GitHub AI agent tools for developers #Shorts Top 3 GitHub AI agent tools for developers Repos in this video: toon-format/toon google/adk-python coze-dev/coze-studio New GitHub finds every day. #Shorts #github #opensource #devtools #coding #ai

    2 views
  4. Three borrowed crafts shrink your token bill: write like ASD-STE100 (one word, one meaning), go caveman on output, ship uniform arrays as TOON. The spec's sample: ~117 JSON tokens become ~66. toon-format/toon #PromptEngineering t.co

    5 views
  5. YouTube · · YouTube description · Tool Staq AI

    Save Claude Code Tokens: 4 Free GitHub Repos 4 free GitHub repos that cut token usage and AI coding costs in Claude Code, Codex and Cursor. Your coding agent can burn thousands of tokens on output you never read. These open-source tools help you track it, shrink it and route around it: 1. codeburn: see where your AI coding spend goes, by project, task and model, then fix the waste getagentseal/codeburn 2. context-mode: keeps raw tool output out of your context window mksglu/context-mode 3. TOON: a compact format for JSON data in LLM prompts toon-format/toon 4. OmniRoute: one local endpoint for many AI providers, with auto routing diegosouzapw/OmniRoute Claims, benchmarks and measurements shown (including 315 KB to 5.4 KB, the 98% reduction, and the TOON 117 to 66 token example) come from the tools' creators, not our own testing. What tools do you use to cut token costs? Tell me in the comments. Follow ToolStaq for AI tools, tips, and what's new. This video uses an AI-generated presenter and voice. ToolStaq is independent and not affiliated with the tools or companies mentioned. #ClaudeCode #AICoding #OpenSource

    1 comment · 130 views
  6. Your Elysia routes are already an MCP server --- title: Your Elysia routes are already an MCP server published: true description: elysia-mcp turns typed Elysia routes into MCP tools, with one route option, OpenAPI docs as tool text, and every call going through your guards. tags: mcp, elysia, bun, typescript canonical_url: cover_image: --- Every MCP server tutorial starts the same way. You write a tool name, a description, an input schema and a handler. Then you look at your API and notice it already has all four, one per route. We ran into this building Proxy Smart, a SMART on FHIR authorization proxy whose admin API runs on Elysia and Bun. Declaring every admin route a second time as an MCP tool, and keeping both in step forever, was a bill we did not want to pay. Our first candidate was our own MCP Generator, which turns an OpenAPI spec into a FastMCP server. It would have worked, but it generates Python, so the MCP server would have run as one more container beside a TypeScript backend. So we built a bridge inside the backend instead. @maxhealth.tech/elysia-mcp reads the route table of the running Elysia app and hands the tools to @maxhealth.tech/mcp-http, our TypeScript MCP HTTP layer. One process, one language, and the routes stay the single source of truth: a route added tomorrow is a tool tomorrow. It is Apache-2.0 and on npm. Quick start Install the package next to its three peers. The host picks their versions, and it needs the v2 MCP SDK (@modelcontextprotocol/server 2.0 or newer). bun add @maxhealth.tech/elysia-mcp elysia @sinclair/typebox @modelcontextprotocol/server Here is a small roles API, served as an MCP endpoint at /mcp: import { Elysia, t } from 'elysia' import { createMcpHandler, hostHeaderValidationResponse, localhostAllowedHostnames } from '@modelcontextprotocol/server' import { mcp, toMcpServer, bearerToken } from '@maxhealth.tech/elysia-mcp' const roles = new Map<string, { name: string }>() const app = new Elysia() .use(mcp()) .get('/admin/roles', () => [...roles.values()], { detail: { summary: 'List roles', description: 'Every realm role.' }, }) .post('/admin/roles', ({ body }) => { roles.set(body.name, body) return body }, { body: t.Object({ name: t.String() }) }) .delete('/admin/roles/:roleName', ({ params }) => roles.delete(params.roleName), { mcp: false }) const handler = createMcpHandler(({ requestInfo }) => toMcpServer(app, { authToken: bearerToken(requestInfo), serverInfo: { name: 'roles', version: '1.0.0' } })) app.all('/mcp', ({ request, body }) => hostHeaderValidationResponse(request, localhostAllowedHostnames()) ?? handler.fetch(request, { parsedBody: body })) tools/list now answers with two tools. The delete route opted out with mcp: false: { "name": "get_admin_roles", "title": "List roles", "description": "Every realm role.", "annotations": { "readOnlyHint": true, "idempotentHint": true, "openWorldHint": false } } { "name": "create_admin_roles", "description": "Create a new admin roles. (Admin only)", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }, "annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false } } Calling create_admin_roles with { "name": "auditor" } returns the body as a text block and as structuredContent. Calling it without name never reaches your handler: the arguments are checked against the route's own TypeBox schema first and come back as an isError result. Two lines in that mount are easy to miss. The SDK's createMcpHandler does not check the Host header, so the route does, against DNS rebinding; list your real hostnames there when you deploy. And Elysia may already have read the request body, so it is handed over as parsedBody. Choosing what becomes a tool .use(mcp()) adds one typed route option: | Option | Effect | | --- | --- | | mcp: true | Exposed, even outside the prefixes | | mcp: false | Never exposed | | mcp: { name, title, description, public } | Exposed, with any of these overriding what is derived | Routes without the option are selected by path prefix, /admin/ and /api/ by default, so nothing becomes a tool merely by existing. Pass prefixes: [] and only marked routes are exposed. A route with OpenAPI docs needs nothing extra. detail.summary becomes the tool's title and detail.description its description. detail.operationId names the tool, and detail.hide: true keeps a route out. The mcp option wins over all of them. Only undocumented routes fall back to a description generated from the name. How a route becomes a tool Names Without an operationId or an explicit name, the tool name is the flattened path with a verb in front: | Method | Path | Tool name | | --- | --- | --- | | GET | /admin/healthcare-users | get_admin_healthcare-users | | GET | /admin/healthcare-users/:userId | get_admin_healthcare-users_userId | | POST | /admin/healthcare-users | create_admin_healthcare-users | | PUT | /admin/smart-apps/:clientId | update_admin_smart-apps_clientId | | DELETE | /admin/roles/:roleName | delete_admin_roles_roleName | Clients cap tool names at 64 characters. A longer name is cut to fit and gets a seven-character FNV-1a digest of the full name as a suffix. FNV-1a gives the same value on every runtime, which matters: a tool name that shifts between deploys breaks prompts people have already saved. If two routes still collide, the second gets a digest of METHOD path so it cannot silently overwrite the first. Annotations REST semantics map cleanly onto MCP's tool annotations: | Method | readOnly | destructive | idempotent | | --- | --- | --- | --- | | GET | yes | no | yes | | PUT | no | no | yes | | PATCH | no | no | no | | POST | no | no | no | | DELETE | no | yes | yes | openWorldHint is false for every route, because the tools act on your own API. Clients use these hints to ask for confirmation before a delete_* call. They are not a security boundary. Schemas TypeBox schemas are already JSON Schema with some Symbol metadata attached, so converting one for the SDK is a JSON round trip followed by the SDK's own fromJsonSchema. There is no field-by-field translation to drift. An MCP client sends one flat argument object and has no idea which value travels in the URL. The package folds path params into the body (or query) schema for that reason. Path params win on collisions and are always required. The success entry of a route's response becomes the tool's outputSchema. Array roots work too, since list routes usually declare t.Array(...). Running it behind auth In production you build one McpServer per request, from a validated token. We put @maxhealth.tech/mcp-http in front for OAuth discovery, CORS and observability, and register the routes with one call: registerRouteTools(server, app, { tools, authToken: token, filter: (name, meta) => !meta.readOnly && (meta.public || roles.includes('admin')), textFormat: 'auto', }) - Tools exist per caller. The filter runs against this request's token, so a caller without the admin role never sees an admin tool in tools/list. - The route table is read once. tools comes from an extractRouteTools call at startup, so a request only filters and registers. - Reads can be resources. Write routes become tools, and registerRouteResources serves GET routes as MCP resources and URI templates. Smaller text blocks with textFormat: 'auto' List endpoints are where an agent's context goes. With textFormat: 'auto', each payload is encoded as compact JSON and as TOON, and the shorter text wins. Measured on our own responses with gpt-tokenizer: | Response | JSON tokens | TOON tokens | Change | | --- | --- | --- | --- | | Roles list (30, flat) | 894 | 515 | −42% | | SMART scopes (40, flat) | 973 | 700 | −28% | | Healthcare users (nested) | 3433 | 3808 | +11% | | Single object | 78 | 85 | +9% | TOON loses on nested data, which is why the package measures both instead of guessing from the shape. 'auto' never produces a longer text block than plain JSON, and structuredContent always stays JSON. Optional UI with MCP Apps Pass a view and the tool result gains a rendered UI in structuredContent, which an MCP Apps host draws in a sandboxed iframe. The model still reads the text block, so the UI costs no tokens. The /prefab subpath ships a default view: lists become searchable tables, single records become detail cards. A view that throws leaves the result untouched, and a server given a view stops advertising outputSchema, since the view no longer matches it. Try it The package pins no MCP protocol version. The SDK negotiates it on the wire, so supporting a newer spec is an SDK bump on your side. Introspection works on Elysia 1.2 and later and reads the same route table on the Elysia 2.0 beta. If you already have an Elysia API with TypeBox schemas, you are about a dozen lines away from an MCP server that respects your guards. We would love to hear where the generated names or descriptions fall short for your routes, and which Elysia setups break the introspection. - Code: Max-Health-Inc/elysia-mcp (Apache-2.0) - npm: @maxhealth.tech/elysia-mcp - HTTP edge: @maxhealth.tech/mcp-http Issues and PRs are welcome.

    2 comments

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

2
PackageVersionDownloads · 30dTrend · 30d
v4.4.0
≥31.9K
v4.4.0
≥3.4M

README badges

Embed a badge in your README or website.

  • toon-format/toon reached #1 in daily stars on gitwal · Oct 27, 2025
  • toon-format/toon reached #2 in weekly stars on gitwal · Week of Oct 27, 2025
Live mentions · last 30 days
toon-format/toon mentions in the last 30 days