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.
Changed files
5 files changed112 additions4 deletionsagainst cacf5e3
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 | ||
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; | ||
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), | ||
| + }; | ||
| + }, | ||
| +}); | ||
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(); | ||
| }); | ||
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"); | ||
| + } | ||
| +}); | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.