Rebasely Beta: data may be purged

examples/MCP-tools Public

Add text_stats

Models miscount words and characters, which matters when a user has a length limit. Counting uses Intl.Segmenter, so an emoji or an accented letter is one character and contractions stay one word. Reading time defaults to 238 words per minute and can be set per call.
7ee134384659f3a592dd430ac5da45b5ab6dd376
Parent cacf5e3 Browse filesHistory

Changed files

5 files changed112 additions4 deletionsagainst cacf5e3

  1. ChangedREADME.md+1−1
  2. Changedsrc/server.ts+3−2
  3. Addedsrc/tools/text-stats.ts+57−0
  4. Changedtest/server.test.ts+10−1
  5. Addedtest/text-stats.test.ts+41−0
ChangedREADME.md+1−1
@@ -1,6 +1,6 @@
# mcp-tools
-A small MCP server with exact, read-only helpers for models, running as a Cloudflare Worker. Tools: `convert_units` and `time_in_zone`. It serves MCP over Streamable HTTP at `/mcp`.
+A small MCP server with exact, read-only helpers for models, running as a Cloudflare Worker. Tools: `convert_units`, `time_in_zone` and `text_stats`. It serves MCP over Streamable HTTP at `/mcp`.
```sh
npm install
View file
Changedsrc/server.ts+3−2
@@ -2,13 +2,14 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { convertUnits } from "./tools/convert-units.ts";
import { ToolError } from "./tools/errors.ts";
+import { textStats } from "./tools/text-stats.ts";
import { timeInZone } from "./tools/time-in-zone.ts";
import type { AnyTool } from "./tools/types.ts";
export const SERVER_INFO = { name: "mcp-tools", version: "0.1.0" };
// Every tool the server offers, in the order tools/list returns them.
-export const TOOLS: AnyTool[] = [convertUnits, timeInZone];
+export const TOOLS: AnyTool[] = [convertUnits, timeInZone, textStats];
export type ServerOptions = {
// The clock tools read; tests pin it.
@@ -19,7 +20,7 @@
// so src/worker.ts makes one per request and each test makes its own.
export function createServer(options: ServerOptions = {}): McpServer {
const server = new McpServer(SERVER_INFO, {
- instructions: "Small, exact helpers: unit conversion and the time in any time zone. Each tool is read-only and answers from its input alone.",
+ instructions: "Small, exact helpers: unit conversion, the time in any time zone and text statistics. Each tool is read-only and answers from its input alone.",
});
for (const tool of TOOLS) register(server, tool, options);
return server;
View file
Addedsrc/tools/text-stats.ts+57−0
@@ -0,0 +1,57 @@
+import { z } from "zod";
+import { defineTool } from "./types.ts";
+
+// A common estimate for silent reading of English prose.
+const DEFAULT_WPM = 238;
+const MAX_LENGTH = 200_000;
+
+const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" });
+const words = new Intl.Segmenter("en", { granularity: "word" });
+const sentences = new Intl.Segmenter("en", { granularity: "sentence" });
+
+function count<T>(items: Iterable<T>, keep: (item: T) => boolean = () => true): number {
+ let n = 0;
+ for (const item of items) if (keep(item)) n++;
+ return n;
+}
+
+function readingTime(seconds: number): string {
+ if (seconds === 0) return "0 min";
+ if (seconds < 60) return "under a minute";
+ return `${Math.round(seconds / 60)} min`;
+}
+
+export const textStats = defineTool({
+ name: "text_stats",
+ title: "Text statistics",
+ description:
+ "Count the characters, words, sentences and paragraphs in a text and estimate its reading time. " +
+ "Use it when the user asks how long a text is or needs to meet a length limit; counting by eye is unreliable. " +
+ "Characters are what a reader sees, so an emoji or an accented letter counts once.",
+ input: z.object({
+ text: z.string().max(MAX_LENGTH).describe(`The text to measure, up to ${MAX_LENGTH} characters.`),
+ words_per_minute: z.number().int().min(50).max(1000).default(DEFAULT_WPM).describe(`Reading speed for the estimate. Defaults to ${DEFAULT_WPM}.`),
+ }),
+ output: z.object({
+ characters: z.number().int(),
+ characters_excluding_spaces: z.number().int(),
+ words: z.number().int(),
+ sentences: z.number().int(),
+ paragraphs: z.number().int(),
+ reading_time_seconds: z.number().int(),
+ reading_time: z.string().describe("The estimate rounded for people, such as 4 min."),
+ }),
+ run({ text, words_per_minute }) {
+ const wordCount = count(words.segment(text), (s) => !!s.isWordLike);
+ const seconds = Math.round((wordCount / words_per_minute) * 60);
+ return {
+ characters: count(graphemes.segment(text)),
+ characters_excluding_spaces: count(graphemes.segment(text), (s) => !/^\s+$/.test(s.segment)),
+ words: wordCount,
+ sentences: count(sentences.segment(text), (s) => /\p{L}|\p{N}/u.test(s.segment)),
+ paragraphs: text.split(/\n\s*\n/).filter((p) => p.trim()).length,
+ reading_time_seconds: seconds,
+ reading_time: readingTime(seconds),
+ };
+ },
+});
View file
Changedtest/server.test.ts+10−1
@@ -12,7 +12,7 @@
test("tools/list returns each tool with its schemas and read-only hint", async () => {
const client = await connect();
const { tools } = await client.listTools();
- assert.deepEqual(tools.map((t) => t.name), ["convert_units", "time_in_zone"]);
+ assert.deepEqual(tools.map((t) => t.name), ["convert_units", "time_in_zone", "text_stats"]);
const convert = tools[0];
assert.deepEqual(convert.inputSchema.required, ["value", "from", "to"]);
assert.ok(convert.outputSchema?.properties?.result);
@@ -36,6 +36,15 @@
{ ...(result.structuredContent as object), formatted: undefined },
{ zone: "Europe/Berlin", instant: "2026-10-06T08:30:00.000Z", date: "2026-10-06", time: "10:30:00", weekday: "Tuesday", utc_offset: "+02:00", utc_offset_minutes: 120, formatted: undefined },
);
+ await client.close();
+});
+
+test("defaults from the input schema reach the tool", async () => {
+ const client = await connect();
+ const result = await client.callTool({ name: "text_stats", arguments: { text: "One two three." } });
+ const stats = result.structuredContent as { words: number; reading_time_seconds: number };
+ assert.equal(stats.words, 3);
+ assert.equal(stats.reading_time_seconds, 1);
await client.close();
});
View file
Addedtest/text-stats.test.ts+41−0
@@ -0,0 +1,41 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { textStats } from "../src/tools/text-stats.ts";
+
+const now = new Date("2026-10-07T12:00:00Z");
+const stats = (text: string, words_per_minute = 238) => textStats.run({ text, words_per_minute }, { now });
+
+test("counts words, sentences and paragraphs", () => {
+ const r = stats("The quick brown fox jumps. It lands well!\n\nA second paragraph? Yes.");
+ assert.equal(r.words, 12);
+ assert.equal(r.sentences, 4);
+ assert.equal(r.paragraphs, 2);
+});
+
+test("counts characters as a reader sees them", () => {
+ const r = stats("café 👍🏽");
+ assert.equal(r.characters, 6);
+ assert.equal(r.characters_excluding_spaces, 5);
+ assert.equal(r.words, 1);
+});
+
+test("keeps contractions whole and splits hyphenated words", () => {
+ assert.equal(stats("Don't stop.").words, 2);
+ assert.equal(stats("state-of-the-art").words, 4);
+});
+
+test("estimates reading time at the given speed", () => {
+ const text = Array(476).fill("word").join(" ");
+ assert.equal(stats(text).reading_time_seconds, 120);
+ assert.equal(stats(text).reading_time, "2 min");
+ assert.equal(stats(text, 476).reading_time, "1 min");
+ assert.equal(stats("Just a few words.").reading_time, "under a minute");
+});
+
+test("empty and blank text is all zeros", () => {
+ for (const text of ["", " \n\n "]) {
+ const r = stats(text);
+ assert.deepEqual([r.words, r.sentences, r.paragraphs, r.reading_time_seconds], [0, 0, 0, 0]);
+ assert.equal(r.reading_time, "0 min");
+ }
+});
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.