essay-stats-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/build/src/core/tools.js +21 -0
- package/build/src/index.js +25 -0
- package/build/src/server.js +9 -0
- package/build/src/text.js +194 -0
- package/build/src/tools.js +250 -0
- package/llms.txt +47 -0
- package/package.json +65 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- First version.
|
|
6
|
+
- count_text counts words, characters, sentences and paragraphs, and estimates pages for a font, font size and line spacing.
|
|
7
|
+
- readability_scores returns Flesch Kincaid grade, Flesch reading ease, Gunning Fog and SMOG.
|
|
8
|
+
- style_flags finds passive voice, repeated sentence openers, filler words and long sentences.
|
|
9
|
+
- check_word_limit compares a text with a word limit, with an optional minimum, a tolerance, and the option to leave out references and quotes.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AnonymousOnyx22
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,81 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Essay Stats MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Offline writing metrics for essays. It covers word and page counts, readability, passive voice, repeated words and reading time, so you can check your work against assignment requirements before you hand it in.
|
|
4
|
+
|
|
5
|
+
It runs on your computer. There is no account, no API key and no network call. Your essay text never leaves your machine.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
Add this to the MCP config of your app:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"essay-stats": {
|
|
15
|
+
"command": "npx",
|
|
16
|
+
"args": ["-y", "essay-stats-mcp"]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
It needs Node.js 20 or newer.
|
|
23
|
+
|
|
24
|
+
Where the config lives:
|
|
25
|
+
|
|
26
|
+
- Claude Desktop: Settings, Developer, Edit Config (`claude_desktop_config.json`).
|
|
27
|
+
- Claude Code: run `claude mcp add essay-stats -- npx -y essay-stats-mcp`.
|
|
28
|
+
- Cursor: Settings, MCP, or `.cursor/mcp.json` in your project.
|
|
29
|
+
- VS Code: `.vscode/mcp.json`. VS Code uses a `servers` key instead of `mcpServers`.
|
|
30
|
+
- Windsurf: `~/.codeium/windsurf/mcp_config.json`.
|
|
31
|
+
|
|
32
|
+
## Tools
|
|
33
|
+
|
|
34
|
+
| Tool | What it does |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `count_text` | Words, characters, sentences, paragraphs, reading time and an estimated page count for a font, size and line spacing. |
|
|
37
|
+
| `readability_scores` | Flesch Kincaid grade, Flesch reading ease, Gunning Fog and SMOG. |
|
|
38
|
+
| `style_flags` | Passive voice, repeated sentence openers, filler words and long sentences. |
|
|
39
|
+
| `check_word_limit` | Compares the text with a word limit. Optional minimum, tolerance, and leaving out references and quotes. |
|
|
40
|
+
|
|
41
|
+
All four tools only read the text you give them. Each one returns a short written answer and the same numbers as structured fields.
|
|
42
|
+
|
|
43
|
+
### count_text
|
|
44
|
+
|
|
45
|
+
Inputs: `text`, and optional `font` (times new roman, arial, calibri, georgia, courier new or verdana), `font_size` in points (default 12) and `spacing` (single, 1.5 or double, default double). The page count is an estimate from typical words per page, so Word or Google Docs may differ by a little.
|
|
46
|
+
|
|
47
|
+
### readability_scores
|
|
48
|
+
|
|
49
|
+
Input: `text`. The grades are roughly US school grades, so 12 is the end of high school. SMOG is designed for 30 sentences or more, so on a short text the result is marked as less reliable.
|
|
50
|
+
|
|
51
|
+
### style_flags
|
|
52
|
+
|
|
53
|
+
Inputs: `text` and optional `max_sentence_words` (default 30). The checks are simple pattern rules. Use them as a prompt to reread a sentence, not as proof of a mistake.
|
|
54
|
+
|
|
55
|
+
### check_word_limit
|
|
56
|
+
|
|
57
|
+
Inputs: `text`, `limit`, and optional `minimum`, `tolerance_percent`, `exclude_references` and `exclude_quotes`. References are cut from a heading line such as References, Works Cited or Bibliography to the end. Quotes are lines starting with `>` and text inside double quotes.
|
|
58
|
+
|
|
59
|
+
## Example prompts
|
|
60
|
+
|
|
61
|
+
- "Count the words in this essay and tell me how many pages it is in Arial 11 point with 1.5 spacing."
|
|
62
|
+
- "What reading level is this essay? Give me the Flesch Kincaid, Gunning Fog and SMOG scores."
|
|
63
|
+
- "Find the passive voice and filler words in my draft."
|
|
64
|
+
- "My limit is 1500 words, not counting the works cited or quotes. Am I over?"
|
|
65
|
+
- "Which sentences are over 25 words long?"
|
|
66
|
+
|
|
67
|
+
## Privacy
|
|
68
|
+
|
|
69
|
+
Everything is calculated locally by a small Node.js program talking to your app over standard input and output. It makes no network requests, stores nothing and needs no keys or passwords. The text you paste is used for the one call and then dropped.
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
npm install
|
|
75
|
+
npm run build
|
|
76
|
+
npm test
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function runTool(tool, args) {
|
|
2
|
+
try {
|
|
3
|
+
const out = tool.run(args);
|
|
4
|
+
return { content: [{ type: "text", text: out.text }], structuredContent: out.structured };
|
|
5
|
+
}
|
|
6
|
+
catch (err) {
|
|
7
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
8
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function registerTools(server, tools) {
|
|
12
|
+
for (const tool of tools) {
|
|
13
|
+
server.registerTool(tool.name, {
|
|
14
|
+
title: tool.title,
|
|
15
|
+
description: tool.description,
|
|
16
|
+
inputSchema: tool.inputSchema,
|
|
17
|
+
outputSchema: tool.outputSchema,
|
|
18
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
19
|
+
}, async (args) => runTool(tool, args));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { createServer, VERSION } from "./server.js";
|
|
4
|
+
function usage() {
|
|
5
|
+
console.log(`essay-stats-mcp ${VERSION}
|
|
6
|
+
|
|
7
|
+
Offline writing metrics for essays: word and page counts, readability, style flags and word limit checks.
|
|
8
|
+
|
|
9
|
+
Add it to your MCP app's config with command "npx" and args ["-y", "essay-stats-mcp"].
|
|
10
|
+
|
|
11
|
+
--help, --version`);
|
|
12
|
+
}
|
|
13
|
+
async function main() {
|
|
14
|
+
const cmd = process.argv[2];
|
|
15
|
+
if (cmd === "--help" || cmd === "-h")
|
|
16
|
+
return usage();
|
|
17
|
+
if (cmd === "--version" || cmd === "-v")
|
|
18
|
+
return console.log(VERSION);
|
|
19
|
+
await createServer().connect(new StdioServerTransport());
|
|
20
|
+
console.error(`essay-stats-mcp ${VERSION} running`);
|
|
21
|
+
}
|
|
22
|
+
main().catch((e) => {
|
|
23
|
+
console.error(e);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { registerTools } from "./core/tools.js";
|
|
3
|
+
import { tools } from "./tools.js";
|
|
4
|
+
export const VERSION = "0.1.0";
|
|
5
|
+
export function createServer() {
|
|
6
|
+
const server = new McpServer({ name: "essay-stats-mcp", version: VERSION });
|
|
7
|
+
registerTools(server, tools);
|
|
8
|
+
return server;
|
|
9
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/** Text analysis helpers. Everything here is pure and works offline. */
|
|
2
|
+
const ABBREVIATIONS = new Set([
|
|
3
|
+
"mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "e.g", "i.e", "fig", "no", "al",
|
|
4
|
+
]);
|
|
5
|
+
export function words(text) {
|
|
6
|
+
return text.match(/[A-Za-z0-9]+(?:['’][A-Za-z]+)*(?:-[A-Za-z0-9]+)*/g) ?? [];
|
|
7
|
+
}
|
|
8
|
+
/** Whitespace separated word count, which is how word processors count. */
|
|
9
|
+
export function wordCount(text) {
|
|
10
|
+
const t = text.trim();
|
|
11
|
+
return t === "" ? 0 : t.split(/\s+/).filter((w) => /[A-Za-z0-9]/.test(w)).length;
|
|
12
|
+
}
|
|
13
|
+
export function splitSentences(text) {
|
|
14
|
+
const out = [];
|
|
15
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
16
|
+
if (!flat)
|
|
17
|
+
return out;
|
|
18
|
+
let start = 0;
|
|
19
|
+
const re = /[.!?]+["'”’)\]]*\s+/g;
|
|
20
|
+
let m;
|
|
21
|
+
while ((m = re.exec(flat))) {
|
|
22
|
+
const end = m.index + m[0].length;
|
|
23
|
+
const before = flat.slice(start, m.index + 1);
|
|
24
|
+
const lastWord = (before.match(/([A-Za-z.]+)\.$/)?.[1] ?? "").toLowerCase().replace(/\.$/, "");
|
|
25
|
+
const next = flat[end] ?? "";
|
|
26
|
+
const isAbbrev = m[0].startsWith(".") && ABBREVIATIONS.has(lastWord);
|
|
27
|
+
const isInitial = /(^|\s)[A-Z]\.$/.test(before.trim());
|
|
28
|
+
if (isAbbrev || isInitial || (m[0].startsWith(".") && /[a-z]/.test(next)))
|
|
29
|
+
continue;
|
|
30
|
+
out.push(flat.slice(start, end).trim());
|
|
31
|
+
start = end;
|
|
32
|
+
}
|
|
33
|
+
const rest = flat.slice(start).trim();
|
|
34
|
+
if (rest)
|
|
35
|
+
out.push(rest);
|
|
36
|
+
return out.filter((s) => /[A-Za-z0-9]/.test(s));
|
|
37
|
+
}
|
|
38
|
+
export function splitParagraphs(text) {
|
|
39
|
+
return text
|
|
40
|
+
.split(/\r?\n\s*\r?\n/)
|
|
41
|
+
.map((p) => p.trim())
|
|
42
|
+
.filter((p) => /[A-Za-z0-9]/.test(p));
|
|
43
|
+
}
|
|
44
|
+
export function syllables(word) {
|
|
45
|
+
let w = word.toLowerCase().replace(/[^a-z]/g, "");
|
|
46
|
+
if (!w)
|
|
47
|
+
return 0;
|
|
48
|
+
if (w.length <= 3)
|
|
49
|
+
return 1;
|
|
50
|
+
w = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "").replace(/^y/, "");
|
|
51
|
+
const groups = w.match(/[aeiouy]{1,2}/g);
|
|
52
|
+
return Math.max(1, groups ? groups.length : 1);
|
|
53
|
+
}
|
|
54
|
+
export const round = (n, places = 1) => {
|
|
55
|
+
const f = 10 ** places;
|
|
56
|
+
return Math.round(n * f) / f;
|
|
57
|
+
};
|
|
58
|
+
/** Words per page for double and single spacing at common fonts and sizes. */
|
|
59
|
+
const FONT_WORDS_PER_PAGE = {
|
|
60
|
+
"times new roman": 550,
|
|
61
|
+
arial: 500,
|
|
62
|
+
calibri: 520,
|
|
63
|
+
georgia: 480,
|
|
64
|
+
"courier new": 400,
|
|
65
|
+
verdana: 430,
|
|
66
|
+
};
|
|
67
|
+
export const FONTS = Object.keys(FONT_WORDS_PER_PAGE);
|
|
68
|
+
export function pageEstimate(wordTotal, font, fontSize, spacing) {
|
|
69
|
+
const base = FONT_WORDS_PER_PAGE[font.trim().toLowerCase()];
|
|
70
|
+
if (base === undefined) {
|
|
71
|
+
throw new Error(`Unknown font "${font}". Use one of: ${FONTS.join(", ")}.`);
|
|
72
|
+
}
|
|
73
|
+
const sizeFactor = 12 / fontSize;
|
|
74
|
+
const spacingFactor = spacing === "single" ? 1 : spacing === "1.5" ? 1 / 1.5 : 0.5;
|
|
75
|
+
const perPage = base * sizeFactor * sizeFactor * spacingFactor;
|
|
76
|
+
return wordTotal / perPage;
|
|
77
|
+
}
|
|
78
|
+
export function counts(text) {
|
|
79
|
+
const ws = words(text);
|
|
80
|
+
let syl = 0;
|
|
81
|
+
let complex = 0;
|
|
82
|
+
for (const w of ws) {
|
|
83
|
+
const s = syllables(w);
|
|
84
|
+
syl += s;
|
|
85
|
+
if (s >= 3)
|
|
86
|
+
complex += 1;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
words: ws.length,
|
|
90
|
+
sentences: splitSentences(text).length,
|
|
91
|
+
syllables: syl,
|
|
92
|
+
complexWords: complex,
|
|
93
|
+
polysyllables: complex,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const IRREGULAR_PARTICIPLES = new Set([
|
|
97
|
+
"done", "made", "given", "taken", "seen", "known", "shown", "written", "chosen", "found", "held",
|
|
98
|
+
"built", "sent", "kept", "left", "lost", "paid", "read", "said", "sold", "told", "thought",
|
|
99
|
+
"brought", "caught", "taught", "bought", "begun", "broken", "driven", "eaten", "fallen", "forgotten",
|
|
100
|
+
"gotten", "grown", "hidden", "ridden", "spoken", "stolen", "thrown", "worn", "won", "set", "put",
|
|
101
|
+
"cut", "hit", "let", "led", "met", "understood", "drawn", "born", "run", "become", "gone",
|
|
102
|
+
]);
|
|
103
|
+
const NOT_PARTICIPLES = new Set([
|
|
104
|
+
"need", "indeed", "speed", "feed", "seed", "red", "bed", "hundred", "embed",
|
|
105
|
+
// Adjectives that look like participles and usually describe a state.
|
|
106
|
+
"tired", "excited", "interested", "worried", "bored", "scared", "surprised", "pleased", "concerned",
|
|
107
|
+
"confused", "relaxed", "exhausted", "satisfied", "disappointed", "amazed", "shocked", "annoyed",
|
|
108
|
+
"supposed", "used", "married", "prepared", "ashamed", "afraid", "determined", "talented", "advanced",
|
|
109
|
+
]);
|
|
110
|
+
export function findPassive(sentences) {
|
|
111
|
+
const hits = [];
|
|
112
|
+
const re = /\b(am|is|are|was|were|be|been|being)\s+(?:(?:not|also|often|always|never|already|still|just|really|then|even|first|later|usually|generally)\s+)*([a-z]+)\b/gi;
|
|
113
|
+
for (const s of sentences) {
|
|
114
|
+
re.lastIndex = 0;
|
|
115
|
+
let m;
|
|
116
|
+
while ((m = re.exec(s))) {
|
|
117
|
+
const part = m[2].toLowerCase();
|
|
118
|
+
if (NOT_PARTICIPLES.has(part))
|
|
119
|
+
continue;
|
|
120
|
+
const regular = part.length > 3 && part.endsWith("ed");
|
|
121
|
+
if (regular || IRREGULAR_PARTICIPLES.has(part)) {
|
|
122
|
+
hits.push({ sentence: s, phrase: m[0] });
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return hits;
|
|
128
|
+
}
|
|
129
|
+
const FILLER_WORDS = [
|
|
130
|
+
"very", "really", "just", "quite", "actually", "basically", "literally", "totally", "simply",
|
|
131
|
+
"rather", "somewhat", "definitely", "extremely", "certainly", "essentially", "virtually",
|
|
132
|
+
];
|
|
133
|
+
const FILLER_PHRASES = [
|
|
134
|
+
"in order to", "due to the fact that", "at the end of the day", "it is important to note that",
|
|
135
|
+
"in my opinion", "a lot of", "kind of", "sort of", "needless to say", "for all intents and purposes",
|
|
136
|
+
"the fact that", "in the event that", "at this point in time",
|
|
137
|
+
];
|
|
138
|
+
export function findFiller(text) {
|
|
139
|
+
const lower = text.toLowerCase();
|
|
140
|
+
const found = [];
|
|
141
|
+
for (const item of [...FILLER_PHRASES, ...FILLER_WORDS]) {
|
|
142
|
+
const re = new RegExp(`\\b${item.replace(/ /g, "\\s+")}\\b`, "g");
|
|
143
|
+
const n = (lower.match(re) ?? []).length;
|
|
144
|
+
if (n > 0)
|
|
145
|
+
found.push({ text: item, count: n });
|
|
146
|
+
}
|
|
147
|
+
return found.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text));
|
|
148
|
+
}
|
|
149
|
+
/** Words that start 3 or more sentences, or 2 or more in a row. */
|
|
150
|
+
export function findRepeatedOpeners(sentences, minRun = 2) {
|
|
151
|
+
const openers = sentences.map((s) => (words(s)[0] ?? "").toLowerCase());
|
|
152
|
+
const byWord = new Map();
|
|
153
|
+
openers.forEach((w, i) => {
|
|
154
|
+
if (!w)
|
|
155
|
+
return;
|
|
156
|
+
byWord.set(w, [...(byWord.get(w) ?? []), i + 1]);
|
|
157
|
+
});
|
|
158
|
+
const hits = [];
|
|
159
|
+
for (const [word, idx] of byWord) {
|
|
160
|
+
let run = 1;
|
|
161
|
+
let longest = 1;
|
|
162
|
+
for (let i = 1; i < idx.length; i++) {
|
|
163
|
+
run = idx[i] === idx[i - 1] + 1 ? run + 1 : 1;
|
|
164
|
+
longest = Math.max(longest, run);
|
|
165
|
+
}
|
|
166
|
+
if (idx.length >= 3 || longest >= minRun)
|
|
167
|
+
hits.push({ word, count: idx.length, sentences: idx });
|
|
168
|
+
}
|
|
169
|
+
return hits.sort((a, b) => b.count - a.count || a.word.localeCompare(b.word));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Cuts out a references section (everything from a heading such as "References",
|
|
173
|
+
* "Works Cited" or "Bibliography" on its own line) and returns what is left.
|
|
174
|
+
*/
|
|
175
|
+
export function stripReferences(text) {
|
|
176
|
+
const re = /^[ \t]*(references|works cited|bibliography|reference list|sources)[ \t]*:?[ \t]*$/im;
|
|
177
|
+
const m = re.exec(text);
|
|
178
|
+
if (!m)
|
|
179
|
+
return { body: text, removed: "" };
|
|
180
|
+
return { body: text.slice(0, m.index), removed: text.slice(m.index) };
|
|
181
|
+
}
|
|
182
|
+
/** Removes quoted passages: block quotes starting with ">" and text inside double quotes. */
|
|
183
|
+
export function stripQuotes(text) {
|
|
184
|
+
const removed = [];
|
|
185
|
+
let body = text.replace(/^[ \t]*>.*$/gm, (m) => {
|
|
186
|
+
removed.push(m);
|
|
187
|
+
return "";
|
|
188
|
+
});
|
|
189
|
+
body = body.replace(/["“]([^"“”\n]{2,}?)["”]/g, (m) => {
|
|
190
|
+
removed.push(m);
|
|
191
|
+
return " ";
|
|
192
|
+
});
|
|
193
|
+
return { body, removed: removed.join("\n") };
|
|
194
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { counts, findFiller, findPassive, findRepeatedOpeners, pageEstimate, round, splitParagraphs, splitSentences, stripQuotes, stripReferences, wordCount, } from "./text.js";
|
|
3
|
+
const textInput = z.string().min(1, "text is empty. Paste the essay text to analyse.");
|
|
4
|
+
function requireText(text) {
|
|
5
|
+
if (!/[A-Za-z0-9]/.test(text)) {
|
|
6
|
+
throw new Error("The text has no words in it. Paste the essay text and try again.");
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const countText = {
|
|
10
|
+
name: "count_text",
|
|
11
|
+
title: "Count words, sentences and pages",
|
|
12
|
+
description: "Count the words, characters, sentences and paragraphs in a text, and estimate how many pages it fills at a given font, font size and line spacing. Inputs: text, and optional font (times new roman, arial, calibri, georgia, courier new or verdana, default times new roman), font_size in points (default 12) and spacing (single, 1.5 or double, default double). The page count is an estimate from typical words per page, so a word processor may differ by a little. Also returns an estimated reading time at 238 words per minute.",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
text: textInput,
|
|
15
|
+
font: z.string().optional().describe("Font name. Default times new roman."),
|
|
16
|
+
font_size: z.number().min(6).max(72).optional().describe("Font size in points. Default 12."),
|
|
17
|
+
spacing: z.enum(["single", "1.5", "double"]).optional().describe("Line spacing. Default double."),
|
|
18
|
+
},
|
|
19
|
+
outputSchema: {
|
|
20
|
+
words: z.number(),
|
|
21
|
+
characters: z.number(),
|
|
22
|
+
characters_no_spaces: z.number(),
|
|
23
|
+
sentences: z.number(),
|
|
24
|
+
paragraphs: z.number(),
|
|
25
|
+
estimated_pages: z.number(),
|
|
26
|
+
font: z.string(),
|
|
27
|
+
font_size: z.number(),
|
|
28
|
+
spacing: z.string(),
|
|
29
|
+
reading_minutes: z.number(),
|
|
30
|
+
},
|
|
31
|
+
run: ({ text, font = "times new roman", font_size = 12, spacing = "double" }) => {
|
|
32
|
+
requireText(text);
|
|
33
|
+
const w = wordCount(text);
|
|
34
|
+
const pages = round(pageEstimate(w, font, font_size, spacing), 1);
|
|
35
|
+
const minutes = round(w / 238, 1);
|
|
36
|
+
const result = {
|
|
37
|
+
words: w,
|
|
38
|
+
characters: text.length,
|
|
39
|
+
characters_no_spaces: text.replace(/\s/g, "").length,
|
|
40
|
+
sentences: splitSentences(text).length,
|
|
41
|
+
paragraphs: splitParagraphs(text).length,
|
|
42
|
+
estimated_pages: pages,
|
|
43
|
+
font: font.trim().toLowerCase(),
|
|
44
|
+
font_size,
|
|
45
|
+
spacing,
|
|
46
|
+
reading_minutes: minutes,
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
text: `${w} words, ${result.characters} characters (${result.characters_no_spaces} without spaces), ${result.sentences} sentences and ${result.paragraphs} paragraphs. About ${pages} pages in ${result.font} ${font_size} point with ${spacing} spacing. Reading time is about ${minutes} minutes.`,
|
|
50
|
+
structured: result,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
function fleschLabel(score) {
|
|
55
|
+
if (score >= 90)
|
|
56
|
+
return "very easy";
|
|
57
|
+
if (score >= 80)
|
|
58
|
+
return "easy";
|
|
59
|
+
if (score >= 70)
|
|
60
|
+
return "fairly easy";
|
|
61
|
+
if (score >= 60)
|
|
62
|
+
return "plain English";
|
|
63
|
+
if (score >= 50)
|
|
64
|
+
return "fairly hard";
|
|
65
|
+
if (score >= 30)
|
|
66
|
+
return "hard";
|
|
67
|
+
return "very hard";
|
|
68
|
+
}
|
|
69
|
+
const readabilityScores = {
|
|
70
|
+
name: "readability_scores",
|
|
71
|
+
title: "Readability scores",
|
|
72
|
+
description: "Score how hard a text is to read with three standard formulas: Flesch Kincaid grade level (plus the Flesch reading ease score), Gunning Fog index and SMOG index. Each grade is roughly a US school grade, so 12 is the end of high school and 16 is a college degree. Input: text. SMOG is built for samples of 30 sentences or more, so on shorter text the tool still returns a number but marks it as less reliable.",
|
|
73
|
+
inputSchema: { text: textInput },
|
|
74
|
+
outputSchema: {
|
|
75
|
+
words: z.number(),
|
|
76
|
+
sentences: z.number(),
|
|
77
|
+
syllables: z.number(),
|
|
78
|
+
complex_words: z.number(),
|
|
79
|
+
flesch_kincaid_grade: z.number(),
|
|
80
|
+
flesch_reading_ease: z.number(),
|
|
81
|
+
reading_ease_label: z.string(),
|
|
82
|
+
gunning_fog: z.number(),
|
|
83
|
+
smog: z.number(),
|
|
84
|
+
smog_reliable: z.boolean(),
|
|
85
|
+
},
|
|
86
|
+
run: ({ text }) => {
|
|
87
|
+
requireText(text);
|
|
88
|
+
const c = counts(text);
|
|
89
|
+
if (c.words === 0 || c.sentences === 0) {
|
|
90
|
+
throw new Error("The text needs at least one word and one sentence to score.");
|
|
91
|
+
}
|
|
92
|
+
const wps = c.words / c.sentences;
|
|
93
|
+
const spw = c.syllables / c.words;
|
|
94
|
+
const fk = 0.39 * wps + 11.8 * spw - 15.59;
|
|
95
|
+
const ease = 206.835 - 1.015 * wps - 84.6 * spw;
|
|
96
|
+
const fog = 0.4 * (wps + 100 * (c.complexWords / c.words));
|
|
97
|
+
const smog = 1.043 * Math.sqrt(c.polysyllables * (30 / c.sentences)) + 3.1291;
|
|
98
|
+
const reliable = c.sentences >= 30;
|
|
99
|
+
const result = {
|
|
100
|
+
words: c.words,
|
|
101
|
+
sentences: c.sentences,
|
|
102
|
+
syllables: c.syllables,
|
|
103
|
+
complex_words: c.complexWords,
|
|
104
|
+
flesch_kincaid_grade: round(fk),
|
|
105
|
+
flesch_reading_ease: round(ease),
|
|
106
|
+
reading_ease_label: fleschLabel(ease),
|
|
107
|
+
gunning_fog: round(fog),
|
|
108
|
+
smog: round(smog),
|
|
109
|
+
smog_reliable: reliable,
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
text: `Flesch Kincaid grade ${result.flesch_kincaid_grade} (reading ease ${result.flesch_reading_ease}, ${result.reading_ease_label}). Gunning Fog ${result.gunning_fog}. SMOG ${result.smog}${reliable ? "" : ", less reliable because the text has fewer than 30 sentences"}. Based on ${c.words} words, ${c.sentences} sentences and ${c.complexWords} complex words.`,
|
|
113
|
+
structured: result,
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
const styleFlags = {
|
|
118
|
+
name: "style_flags",
|
|
119
|
+
title: "Style flags",
|
|
120
|
+
description: "Flag common style problems in an essay: sentences in the passive voice, sentences that start with the same word as a neighbour (or three or more times), filler words and phrases such as very, just, in order to, and sentences that are too long. Inputs: text, and optional max_sentence_words (default 30), the length above which a sentence counts as long. The checks are simple pattern rules, so treat them as prompts to reread, not as errors.",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
text: textInput,
|
|
123
|
+
max_sentence_words: z.number().int().min(5).max(200).optional().describe("Sentence length that counts as long. Default 30."),
|
|
124
|
+
},
|
|
125
|
+
outputSchema: {
|
|
126
|
+
sentences: z.number(),
|
|
127
|
+
passive_count: z.number(),
|
|
128
|
+
passive_sentences: z.array(z.object({ phrase: z.string(), sentence: z.string() })),
|
|
129
|
+
repeated_openers: z.array(z.object({ word: z.string(), count: z.number(), sentences: z.array(z.number()) })),
|
|
130
|
+
filler: z.array(z.object({ text: z.string(), count: z.number() })),
|
|
131
|
+
filler_count: z.number(),
|
|
132
|
+
long_sentences: z.array(z.object({ number: z.number(), words: z.number(), sentence: z.string() })),
|
|
133
|
+
max_sentence_words: z.number(),
|
|
134
|
+
},
|
|
135
|
+
run: ({ text, max_sentence_words = 30 }) => {
|
|
136
|
+
requireText(text);
|
|
137
|
+
const sentences = splitSentences(text);
|
|
138
|
+
const passive = findPassive(sentences);
|
|
139
|
+
const openers = findRepeatedOpeners(sentences);
|
|
140
|
+
const filler = findFiller(text);
|
|
141
|
+
const long = sentences
|
|
142
|
+
.map((s, i) => ({ number: i + 1, words: wordCount(s), sentence: s }))
|
|
143
|
+
.filter((s) => s.words > max_sentence_words);
|
|
144
|
+
const fillerCount = filler.reduce((n, f) => n + f.count, 0);
|
|
145
|
+
const lines = [
|
|
146
|
+
`${sentences.length} sentences checked.`,
|
|
147
|
+
`Passive voice: ${passive.length}${passive.length ? " (" + passive.slice(0, 3).map((p) => `"${p.phrase}"`).join(", ") + (passive.length > 3 ? ", ..." : "") + ")" : ""}.`,
|
|
148
|
+
`Repeated openers: ${openers.length ? openers.map((o) => `"${o.word}" x${o.count}`).join(", ") : "none"}.`,
|
|
149
|
+
`Filler: ${fillerCount ? filler.map((f) => `"${f.text}" x${f.count}`).join(", ") : "none"}.`,
|
|
150
|
+
`Long sentences over ${max_sentence_words} words: ${long.length ? long.map((l) => `#${l.number} (${l.words} words)`).join(", ") : "none"}.`,
|
|
151
|
+
];
|
|
152
|
+
return {
|
|
153
|
+
text: lines.join("\n"),
|
|
154
|
+
structured: {
|
|
155
|
+
sentences: sentences.length,
|
|
156
|
+
passive_count: passive.length,
|
|
157
|
+
passive_sentences: passive.map((p) => ({ phrase: p.phrase, sentence: p.sentence })),
|
|
158
|
+
repeated_openers: openers,
|
|
159
|
+
filler,
|
|
160
|
+
filler_count: fillerCount,
|
|
161
|
+
long_sentences: long,
|
|
162
|
+
max_sentence_words,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
const checkWordLimit = {
|
|
168
|
+
name: "check_word_limit",
|
|
169
|
+
title: "Check a word limit",
|
|
170
|
+
description: "Compare an essay against a word limit. Inputs: text, limit (the maximum word count), optional minimum (a lower bound), optional tolerance_percent (how far over the limit is still acceptable, default 0, a common rule is 10), optional exclude_references (cut everything from a heading line such as References, Works Cited or Bibliography to the end) and optional exclude_quotes (leave out block quotes that start with > and text inside double quotes). Returns the counted words, how many are left or over, and what was excluded.",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
text: textInput,
|
|
173
|
+
limit: z.number().int().positive().describe("Maximum number of words."),
|
|
174
|
+
minimum: z.number().int().min(0).optional().describe("Minimum number of words."),
|
|
175
|
+
tolerance_percent: z.number().min(0).max(100).optional().describe("Percent over the limit that is still fine. Default 0."),
|
|
176
|
+
exclude_references: z.boolean().optional().describe("Leave out the references section. Default false."),
|
|
177
|
+
exclude_quotes: z.boolean().optional().describe("Leave out quoted passages. Default false."),
|
|
178
|
+
},
|
|
179
|
+
outputSchema: {
|
|
180
|
+
status: z.enum(["under_minimum", "within_limit", "within_tolerance", "over_limit"]),
|
|
181
|
+
total_words: z.number(),
|
|
182
|
+
counted_words: z.number(),
|
|
183
|
+
excluded_reference_words: z.number(),
|
|
184
|
+
excluded_quote_words: z.number(),
|
|
185
|
+
limit: z.number(),
|
|
186
|
+
minimum: z.number().nullable(),
|
|
187
|
+
allowed_maximum: z.number(),
|
|
188
|
+
words_remaining: z.number(),
|
|
189
|
+
words_over: z.number(),
|
|
190
|
+
},
|
|
191
|
+
run: ({ text, limit, minimum, tolerance_percent = 0, exclude_references = false, exclude_quotes = false }) => {
|
|
192
|
+
requireText(text);
|
|
193
|
+
if (minimum !== undefined && minimum > limit) {
|
|
194
|
+
throw new Error(`minimum (${minimum}) is above limit (${limit}). Check the two numbers.`);
|
|
195
|
+
}
|
|
196
|
+
const total = wordCount(text);
|
|
197
|
+
let body = text;
|
|
198
|
+
let refWords = 0;
|
|
199
|
+
let quoteWords = 0;
|
|
200
|
+
if (exclude_references) {
|
|
201
|
+
const r = stripReferences(body);
|
|
202
|
+
body = r.body;
|
|
203
|
+
refWords = wordCount(r.removed);
|
|
204
|
+
}
|
|
205
|
+
if (exclude_quotes) {
|
|
206
|
+
const q = stripQuotes(body);
|
|
207
|
+
body = q.body;
|
|
208
|
+
quoteWords = wordCount(q.removed);
|
|
209
|
+
}
|
|
210
|
+
const counted = wordCount(body);
|
|
211
|
+
const allowed = Math.floor(limit * (1 + tolerance_percent / 100));
|
|
212
|
+
let status;
|
|
213
|
+
if (minimum !== undefined && counted < minimum)
|
|
214
|
+
status = "under_minimum";
|
|
215
|
+
else if (counted <= limit)
|
|
216
|
+
status = "within_limit";
|
|
217
|
+
else if (counted <= allowed)
|
|
218
|
+
status = "within_tolerance";
|
|
219
|
+
else
|
|
220
|
+
status = "over_limit";
|
|
221
|
+
const remaining = Math.max(0, limit - counted);
|
|
222
|
+
const over = Math.max(0, counted - limit);
|
|
223
|
+
const summary = {
|
|
224
|
+
under_minimum: `Under the minimum. ${counted} words counted, ${minimum - counted} short of ${minimum}.`,
|
|
225
|
+
within_limit: `Within the limit. ${counted} of ${limit} words, ${remaining} left.`,
|
|
226
|
+
within_tolerance: `Over the limit of ${limit} by ${over} words, but inside the ${tolerance_percent} percent tolerance (up to ${allowed}).`,
|
|
227
|
+
over_limit: `Over the limit. ${counted} words counted against ${limit}, so ${over} to cut${tolerance_percent ? ` (tolerance allows up to ${allowed})` : ""}.`,
|
|
228
|
+
};
|
|
229
|
+
const excluded = [
|
|
230
|
+
exclude_references ? `${refWords} reference words left out` : "",
|
|
231
|
+
exclude_quotes ? `${quoteWords} quote words left out` : "",
|
|
232
|
+
].filter(Boolean);
|
|
233
|
+
return {
|
|
234
|
+
text: summary[status] + (excluded.length ? ` (${total} words in total, ${excluded.join(", ")}.)` : ""),
|
|
235
|
+
structured: {
|
|
236
|
+
status,
|
|
237
|
+
total_words: total,
|
|
238
|
+
counted_words: counted,
|
|
239
|
+
excluded_reference_words: refWords,
|
|
240
|
+
excluded_quote_words: quoteWords,
|
|
241
|
+
limit,
|
|
242
|
+
minimum: minimum ?? null,
|
|
243
|
+
allowed_maximum: allowed,
|
|
244
|
+
words_remaining: remaining,
|
|
245
|
+
words_over: over,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
export const tools = [countText, readabilityScores, styleFlags, checkWordLimit];
|
package/llms.txt
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Essay Stats MCP
|
|
2
|
+
|
|
3
|
+
> An MCP server with offline writing metrics for essays. It counts words and pages, scores readability, flags style problems and checks word limits. No account, no API key, no network.
|
|
4
|
+
|
|
5
|
+
Package: essay-stats-mcp (npm). Registry name: io.github.AnonymousOnyx22/essay-stats-mcp. Runs over stdio. Needs Node.js 20 or newer.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Add this to the MCP config of Claude Desktop, Claude Code, Cursor, VS Code or Windsurf:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"essay-stats": {
|
|
15
|
+
"command": "npx",
|
|
16
|
+
"args": ["-y", "essay-stats-mcp"]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
VS Code uses a `servers` key instead of `mcpServers`.
|
|
23
|
+
|
|
24
|
+
## Tools
|
|
25
|
+
|
|
26
|
+
All tools are read only and work on text passed in the call.
|
|
27
|
+
|
|
28
|
+
- count_text: words, characters, sentences, paragraphs, reading time and estimated pages. Inputs: text, optional font (times new roman, arial, calibri, georgia, courier new, verdana), font_size (default 12) and spacing (single, 1.5, double; default double).
|
|
29
|
+
- readability_scores: Flesch Kincaid grade, Flesch reading ease, Gunning Fog and SMOG. Input: text. SMOG is marked less reliable under 30 sentences.
|
|
30
|
+
- style_flags: passive voice, repeated sentence openers, filler words and long sentences. Inputs: text, optional max_sentence_words (default 30).
|
|
31
|
+
- check_word_limit: compares text with a limit. Inputs: text, limit, optional minimum, tolerance_percent, exclude_references and exclude_quotes. Returns a status of under_minimum, within_limit, within_tolerance or over_limit.
|
|
32
|
+
|
|
33
|
+
## Example prompts
|
|
34
|
+
|
|
35
|
+
- "Count the words in this essay and estimate the pages in Times New Roman 12 double spaced."
|
|
36
|
+
- "Give me the Flesch Kincaid, Gunning Fog and SMOG scores for this paper."
|
|
37
|
+
- "Flag passive voice and filler words in my draft."
|
|
38
|
+
- "Is this under 2000 words if I leave out the works cited and quotes?"
|
|
39
|
+
|
|
40
|
+
## Privacy
|
|
41
|
+
|
|
42
|
+
Everything runs locally. The server makes no network requests and stores nothing. No keys or passwords are needed.
|
|
43
|
+
|
|
44
|
+
## Links
|
|
45
|
+
|
|
46
|
+
- Source: https://github.com/AnonymousOnyx22/essay-stats-mcp
|
|
47
|
+
- License: MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "essay-stats-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Offline writing metrics for essays. Word and page counts, readability scores, passive voice, repeated openers, filler words, long sentences and word limit checks, so students can check their work against assignment requirements. No account, no network.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "build/src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"essay-stats-mcp": "build/src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"build/src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"llms.txt",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc && node -e \"require('fs').chmodSync('build/src/index.js', 0o755)\"",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"start": "node build/src/index.js",
|
|
24
|
+
"prepublishOnly": "npm run build && npm test"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"essay stats mcp",
|
|
28
|
+
"essay word counter",
|
|
29
|
+
"word count",
|
|
30
|
+
"page count",
|
|
31
|
+
"readability",
|
|
32
|
+
"flesch kincaid",
|
|
33
|
+
"gunning fog",
|
|
34
|
+
"smog index",
|
|
35
|
+
"passive voice",
|
|
36
|
+
"writing metrics",
|
|
37
|
+
"word limit",
|
|
38
|
+
"reading time",
|
|
39
|
+
"style checker",
|
|
40
|
+
"student writing",
|
|
41
|
+
"mcp",
|
|
42
|
+
"mcp server",
|
|
43
|
+
"model context protocol",
|
|
44
|
+
"claude desktop",
|
|
45
|
+
"cursor"
|
|
46
|
+
],
|
|
47
|
+
"author": "AnonymousOnyx22",
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/AnonymousOnyx22/essay-stats-mcp.git"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/AnonymousOnyx22/essay-stats-mcp#readme",
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/AnonymousOnyx22/essay-stats-mcp/issues"
|
|
56
|
+
},
|
|
57
|
+
"mcpName": "io.github.AnonymousOnyx22/essay-stats-mcp",
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
60
|
+
"zod": "^4.6.7"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/node": "^26.6.5",
|
|
64
|
+
"typescript": "^7.0.2",
|
|
65
|
+
"vitest": "^5.0.5"
|
|
66
|
+
}
|
|
67
|
+
}
|