#!/usr/bin/env node
/**
* mcp-character-tools
*
* A comprehensive MCP server providing character and text analysis tools
* to help LLMs work with individual characters, which they struggle with
* due to tokenization.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Import tool implementations
import {
countLetter,
countLetters,
countSubstring,
letterFrequency,
} from './tools/counting.js';
import {
spellWord,
charAt,
nthCharacter,
wordLength,
reverseText,
} from './tools/spelling.js';
import {
compareTexts,
analyzeSentence,
batchCount,
} from './tools/analysis.js';
import {
getTrickyWords,
getTrickyWordByName,
} from './resources/tricky-words.js';
// Create the MCP server
const server = new McpServer({
name: "mcp-character-tools",
version: "1.0.0",
});
// ============================================================================
// COUNTING TOOLS
// ============================================================================
server.registerTool(
"count_letter",
{
title: "Count Letter",
description: `Count occurrences of a specific letter in text.
Returns the count, positions, a visual breakdown showing where each letter appears, and a density percentage.
Args:
- text (string): The text to analyze
- letter (string): The single letter to count
- case_sensitive (boolean): Whether to match case exactly (default: false)
Returns: count, positions array, visual breakdown, and density summary.
Example: count_letter("strawberry", "r") → count: 3, positions: [2, 5, 8]`,
inputSchema: z.object({
text: z.string().min(1).describe("The text to analyze"),
letter: z.string().length(1).describe("The letter to count"),
case_sensitive: z.boolean().default(false).describe("Match case exactly"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = countLetter({
text: params.text,
letter: params.letter,
case_sensitive: params.case_sensitive,
});
return {
content: [{
type: "text" as const,
text: `${result.density}\n\n${result.visual}\n\nResult: ${JSON.stringify({ count: result.count, positions: result.positions })}`
}],
};
}
);
server.registerTool(
"count_letters",
{
title: "Count Multiple Letters",
description: `Count occurrences of multiple letters at once.
Efficiently counts several letters in a single call.
Args:
- text (string): The text to analyze
- letters (string[]): Array of letters to count
- case_sensitive (boolean): Match case exactly (default: false)
Returns: Results for each letter with counts and positions.
Example: count_letters("strawberry", ["r", "s", "e"]) → r: 3, s: 1, e: 1`,
inputSchema: z.object({
text: z.string().min(1).describe("The text to analyze"),
letters: z.array(z.string().length(1)).min(1).describe("Letters to count"),
case_sensitive: z.boolean().default(false).describe("Match case exactly"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = countLetters({
text: params.text,
letters: params.letters,
case_sensitive: params.case_sensitive,
});
const summary = result.results
.map(r => `'${r.letter}': ${r.count} at [${r.positions.join(', ')}]`)
.join('\n');
return {
content: [{ type: "text" as const, text: `Letter counts in "${result.text}":\n${summary}\n\nTotal: ${result.total_matches}` }],
};
}
);
server.registerTool(
"count_substring",
{
title: "Count Substring",
description: `Count occurrences of a substring or pattern in text.
Can count overlapping or non-overlapping matches.
Args:
- text (string): The text to search in
- substring (string): The pattern to find
- case_sensitive (boolean): Match case exactly (default: false)
- overlapping (boolean): Count overlapping matches (default: false)
Returns: count and positions of each match.
Example: count_substring("banana", "ana", overlapping=true) → count: 2, positions: [1, 3]`,
inputSchema: z.object({
text: z.string().min(1).describe("The text to search in"),
substring: z.string().min(1).describe("The pattern to find"),
case_sensitive: z.boolean().default(false).describe("Match case exactly"),
overlapping: z.boolean().default(false).describe("Count overlapping matches"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = countSubstring({
text: params.text,
substring: params.substring,
case_sensitive: params.case_sensitive,
overlapping: params.overlapping,
});
return {
content: [{ type: "text" as const, text: `Found "${result.substring}" ${result.count} time(s) in "${result.text}" at positions: [${result.positions.join(', ')}]` }],
};
}
);
server.registerTool(
"letter_frequency",
{
title: "Letter Frequency",
description: `Get frequency distribution of all characters in text.
Provides a complete breakdown of character frequencies.
Args:
- text (string): The text to analyze
- case_sensitive (boolean): Distinguish upper/lowercase (default: false)
- include_spaces (boolean): Include spaces in count (default: false)
- include_punctuation (boolean): Include punctuation (default: false)
- letters_only (boolean): Only count a-z letters (default: true)
Returns: Frequency map, sorted list, most/least common characters.
Example: letter_frequency("hello") → h: 1, e: 1, l: 2, o: 1`,
inputSchema: z.object({
text: z.string().min(1).describe("The text to analyze"),
case_sensitive: z.boolean().default(false).describe("Distinguish upper/lowercase"),
include_spaces: z.boolean().default(false).describe("Include spaces"),
include_punctuation: z.boolean().default(false).describe("Include punctuation"),
letters_only: z.boolean().default(true).describe("Only count a-z letters"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = letterFrequency({
text: params.text,
case_sensitive: params.case_sensitive,
include_spaces: params.include_spaces,
include_punctuation: params.include_punctuation,
letters_only: params.letters_only,
});
return {
content: [{ type: "text" as const, text: `Frequency analysis of "${result.text}":\n\n${result.frequency_table}\n\nMost common: ${result.most_common.map(c => `'${c.char}': ${c.count}`).join(', ')}` }],
};
}
);
// ============================================================================
// SPELLING TOOLS
// ============================================================================
server.registerTool(
"spell_word",
{
title: "Spell Word",
description: `Break text into individual characters with optional indices.
Perfect for verifying character-by-character content.
Args:
- text (string): The text to spell out
- include_indices (boolean): Include position numbers (default: true)
Returns: Array of characters, indexed list, spelled out string.
Example: spell_word("cat") → ['c', 'a', 't'] with indices [0:'c', 1:'a', 2:'t']`,
inputSchema: z.object({
text: z.string().min(1).describe("The text to spell out"),
include_indices: z.boolean().default(true).describe("Include position numbers"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = spellWord({
text: params.text,
include_indices: params.include_indices,
});
return {
content: [{ type: "text" as const, text: `"${result.text}" (${result.length} chars): ${result.spelled_out}` }],
};
}
);
server.registerTool(
"char_at",
{
title: "Character At Index",
description: `Get the character at a specific index (0-based). Supports negative indices.
Args:
- text (string): The text to index into
- index (number): Position (0-based, negative counts from end)
Returns: The character at that position, or error if out of bounds.
Example: char_at("hello", 1) → 'e'; char_at("hello", -1) → 'o'`,
inputSchema: z.object({
text: z.string().min(1).describe("The text to index into"),
index: z.number().int().describe("Position (0-based, negative from end)"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = charAt({
text: params.text,
index: params.index,
});
const text = result.valid
? `Character at index ${result.index} in "${result.text}" is '${result.character}'`
: result.error || "Invalid index";
return {
content: [{ type: "text" as const, text }],
};
}
);
server.registerTool(
"nth_character",
{
title: "Nth Character",
description: `Get the nth character (1-based, human-friendly numbering).
"What's the 3rd letter?" uses position=3.
Args:
- text (string): The text to examine
- position (number): Which character (1 = first, 2 = second, etc.)
- from_end (boolean): Count from end instead (default: false)
Returns: The character and a human-readable description.
Example: nth_character("hello", 2) → 'e' (the 2nd character)`,
inputSchema: z.object({
text: z.string().min(1).describe("The text to examine"),
position: z.number().int().min(1).describe("Which character (1-based)"),
from_end: z.boolean().default(false).describe("Count from end instead"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = nthCharacter({
text: params.text,
position: params.position,
from_end: params.from_end,
});
return {
content: [{ type: "text" as const, text: result.valid ? result.description : result.error || "Invalid position" }],
};
}
);
server.registerTool(
"word_length",
{
title: "Word Length",
description: `Get the exact length of text with detailed breakdown.
Args:
- text (string): The text to measure
- count_spaces (boolean): Include spaces in length (default: true)
Returns: Total length, length without spaces, space count, word count.
Example: word_length("hello world") → 11 total, 10 without spaces, 2 words`,
inputSchema: z.object({
text: z.string().describe("The text to measure"),
count_spaces: z.boolean().default(true).describe("Include spaces in length"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = wordLength({
text: params.text,
count_spaces: params.count_spaces,
});
return {
content: [{ type: "text" as const, text: result.description }],
};
}
);
server.registerTool(
"reverse_text",
{
title: "Reverse Text",
description: `Reverse text character-by-character or word-by-word.
Also detects if the text is a palindrome.
Args:
- text (string): The text to reverse
- reverse_words_only (boolean): Reverse word order only, not characters (default: false)
Returns: Reversed text, palindrome detection.
Example: reverse_text("hello") → "olleh"; reverse_text("racecar") → "racecar" (palindrome!)`,
inputSchema: z.object({
text: z.string().min(1).describe("The text to reverse"),
reverse_words_only: z.boolean().default(false).describe("Reverse word order only"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = reverseText({
text: params.text,
reverse_words_only: params.reverse_words_only,
});
return {
content: [{ type: "text" as const, text: result.description }],
};
}
);
// ============================================================================
// ANALYSIS TOOLS
// ============================================================================
server.registerTool(
"compare_texts",
{
title: "Compare Texts",
description: `Compare letter frequencies between two texts.
Useful for analyzing similarity or differences.
Args:
- text1 (string): First text
- text2 (string): Second text
- case_sensitive (boolean): Distinguish case (default: false)
Returns: Common characters, unique to each, frequency comparison, similarity score.
Example: compare_texts("hello", "world") → common: ['l', 'o'], unique_to_text1: ['h', 'e'], etc.`,
inputSchema: z.object({
text1: z.string().min(1).describe("First text"),
text2: z.string().min(1).describe("Second text"),
case_sensitive: z.boolean().default(false).describe("Distinguish case"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = compareTexts({
text1: params.text1,
text2: params.text2,
case_sensitive: params.case_sensitive,
});
return {
content: [{ type: "text" as const, text: `${result.summary}\n\nCommon: [${result.common_characters.join(', ')}]\nOnly in text1: [${result.unique_to_text1.join(', ')}]\nOnly in text2: [${result.unique_to_text2.join(', ')}]` }],
};
}
);
server.registerTool(
"analyze_sentence",
{
title: "Analyze Sentence",
description: `Analyze a sentence word-by-word for a specific letter.
Shows exactly how many times a letter appears in each word.
Args:
- text (string): The sentence to analyze
- letter (string): The letter to count
- case_sensitive (boolean): Match case exactly (default: false)
Returns: Per-word breakdown with counts and positions.
Example: analyze_sentence("The strawberry was very ripe", "r") → per-word counts`,
inputSchema: z.object({
text: z.string().min(1).describe("The sentence to analyze"),
letter: z.string().length(1).describe("The letter to count"),
case_sensitive: z.boolean().default(false).describe("Match case exactly"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = analyzeSentence({
text: params.text,
letter: params.letter,
case_sensitive: params.case_sensitive,
});
return {
content: [{ type: "text" as const, text: `${result.summary}\n\n${result.breakdown_table}` }],
};
}
);
server.registerTool(
"batch_count",
{
title: "Batch Count",
description: `Count a letter across multiple words at once.
Efficiently process a list of words.
Args:
- words (string[]): Array of words to analyze
- letter (string): The letter to count
- case_sensitive (boolean): Match case exactly (default: false)
Returns: Results for each word, totals, sorted by count.
Example: batch_count(["strawberry", "raspberry", "blueberry"], "r") → individual and total counts`,
inputSchema: z.object({
words: z.array(z.string().min(1)).min(1).describe("Words to analyze"),
letter: z.string().length(1).describe("The letter to count"),
case_sensitive: z.boolean().default(false).describe("Match case exactly"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const result = batchCount({
words: params.words,
letter: params.letter,
case_sensitive: params.case_sensitive,
});
const breakdown = result.results
.map(r => `"${r.word}": ${r.count} at [${r.positions.join(', ')}]`)
.join('\n');
return {
content: [{ type: "text" as const, text: `${result.summary}\n\n${breakdown}` }],
};
}
);
// ============================================================================
// TRICKY WORDS TOOLS
// ============================================================================
server.registerTool(
"get_tricky_words",
{
title: "Get Tricky Words",
description: `Get a list of words that are commonly miscounted by LLMs.
These are words with double letters, repeated patterns, or other features that cause counting errors.
Returns: List of tricky words with correct counts and explanations.
Example: Returns "strawberry" with explanation that it has 3 r's, not 2.`,
inputSchema: z.object({}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async () => {
const result = getTrickyWords();
const summary = result.words
.slice(0, 10)
.map(w => `"${w.word}" '${w.letter}': ${w.count} (often miscounted as ${w.common_mistake})`)
.join('\n');
return {
content: [{ type: "text" as const, text: `${result.total_words} tricky words in database.\n\nExamples:\n${summary}\n\n...and ${result.total_words - 10} more.` }],
};
}
);
server.registerTool(
"check_tricky_word",
{
title: "Check Tricky Word",
description: `Look up a specific word to see if it's a commonly miscounted word.
Args:
- word (string): The word to check
Returns: Information about common mistakes if it's a tricky word, or empty if not.
Example: check_tricky_word("strawberry") → explains the 3 r's and common mistake of counting 2`,
inputSchema: z.object({
word: z.string().min(1).describe("The word to check"),
}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (params) => {
const entries = getTrickyWordByName(params.word);
const is_tricky = entries.length > 0;
let text: string;
if (is_tricky) {
text = entries
.map(e => `"${e.word}" has ${e.count} '${e.letter}'(s) at positions [${e.positions.join(', ')}].\nCommon mistake: counting ${e.common_mistake} instead.\n${e.explanation}`)
.join('\n\n');
} else {
text = `"${params.word}" is not in the tricky words database. Use count_letter to analyze it.`;
}
return {
content: [{ type: "text" as const, text }],
};
}
);
// ============================================================================
// SERVER STARTUP
// ============================================================================
async function main(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("mcp-character-tools v1.0.0 running on stdio");
}
main().catch((error: unknown) => {
console.error("Server error:", error);
process.exit(1);
});