@roughen/mcp 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Upforge
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 ADDED
@@ -0,0 +1,46 @@
1
+ # @roughen/mcp
2
+
3
+ A local MCP server that lets any AI tool check its own writing against measured human-writing baselines, then verify its rewrite. It runs on your machine over stdio. There are no network calls or model calls, and it never writes a file. Your model does the rewriting; Roughen measures before and after.
4
+
5
+ ## Tools
6
+
7
+ | Tool | What it does |
8
+ |---|---|
9
+ | `roughen_check` | Checks a draft and returns a revision brief: which passages to rewrite, how many, why, and what must not change. `returnFixed: true` also returns the text with safe mechanical fixes applied. |
10
+ | `roughen_check_file` | The same for a local file: Markdown, MDX, HTML, text, or the copy in a `.tsx`/`.jsx`/`.ts`/`.js` component. It uses the nearest `roughen.config`. |
11
+ | `roughen_compare` | Compares an original with a revision. It checks that every figure, link, code sample, quotation and heading survived, that nothing was added, that length stayed within 15%, and that the flagged habits dropped. Then it says which version to use. |
12
+ | `roughen_site` | For a Next.js app-router project, returns the copy pass plan: copy and findings per route and per file, copy no route ships, and work groups of files with no overlap. Pass a `group` id to get that group's revision briefs. Protected strings (H1s, titles, FAQ questions, keywords, quotes) are marked and never offered for rewriting. |
13
+ | `roughen_verify` | Compares every changed file under a path with its version at a git commit (default `HEAD`). Fails on code changes, files that no longer parse, edited protected strings, lost or invented numbers and links, new banned characters, rewritten copy beyond 15% of its length, and habits that grew. |
14
+
15
+ The `humanize` prompt runs the whole loop: check, revise what's flagged, compare, and return the accepted version. The `site_pass` prompt does the same for one work group of a site: plan, edit, verify. Every tool is read-only; `roughen_verify` reads old versions through git.
16
+
17
+ ## Set up
18
+
19
+ Until the package is published, point clients at this checkout, after running `pnpm install` at the repo root. Node 22.13 or newer is required.
20
+
21
+ **Claude Desktop.** Add this to `~/Library/Application Support/Claude/claude_desktop_config.json` and restart the app:
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "roughen": {
27
+ "command": "node",
28
+ "args": ["/Volumes/Uptrade Media/GitHub/Upforge/roughen/packages/mcp/bin/roughen-mcp.mjs"]
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ **Cursor.** Use the same `mcpServers` entry in `~/.cursor/mcp.json`.
35
+
36
+ **Claude Code.** Register the server for all your projects:
37
+
38
+ ```bash
39
+ claude mcp add --scope user roughen -- node "/Volumes/Uptrade Media/GitHub/Upforge/roughen/packages/mcp/bin/roughen-mcp.mjs"
40
+ ```
41
+
42
+ In Claude Code, the [Roughen plugin](../../plugins/roughen/README.md) adds a hook that checks files as Claude writes them. The MCP server is for tools without hooks, and for checking copy you're drafting in chat.
43
+
44
+ ## What it checks
45
+
46
+ Everything in [`@roughen/core`](../core/README.md). The rules that matter for generated copy are the ones measured against 110 human documents: list saturation, contrast frames, generalizing hedges, reveal frames, em-dash and semicolon density, long sentences, and flat rhythm. Each fires only above what 95% of human documents do. [baselines-001](../../research/corpus/reports/baselines-001.md) and [claude-001](../../research/corpus/reports/claude-001.md) have the numbers and their limits. MIT.
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // Roughen's local MCP server over stdio. Stdout carries the protocol, so
3
+ // diagnostics go to stderr only.
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { createServer, serverVersion, coreVersion } from '../lib/server.mjs';
6
+
7
+ if (process.argv.includes('--version')) {
8
+ process.stdout.write(`roughen-mcp ${serverVersion} (core ${coreVersion})\n`);
9
+ } else {
10
+ await createServer().connect(new StdioServerTransport());
11
+ process.stderr.write(`roughen-mcp ${serverVersion} (core ${coreVersion}) ready on stdio\n`);
12
+ }
package/lib/server.mjs ADDED
@@ -0,0 +1,191 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { z } from 'zod';
5
+ import { judgeRevision, listRules, version as coreVersion } from '@roughen/core';
6
+ import { loadConfig, included } from '@roughen/cli/lib/config.mjs';
7
+ import { formatFor } from '@roughen/cli/lib/lint-file.mjs';
8
+ import { review, mechanicalSummary } from '@roughen/cli/lib/review.mjs';
9
+ import { planSite, renderPlan, renderGroupBrief } from '@roughen/cli/lib/site.mjs';
10
+ import { verifyGit, renderVerify } from '@roughen/cli/lib/verify.mjs';
11
+
12
+ export const serverVersion = '0.2.0';
13
+ const proseFormat = z.enum(['md', 'mdx', 'html', 'text']).default('md').describe('How to read the text: Markdown (default), MDX, an HTML fragment, or plain text.');
14
+ const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
15
+
16
+ const text = (body) => ({ content: [{ type: 'text', text: body }] });
17
+ const failure = (message) => ({ content: [{ type: 'text', text: message }], isError: true });
18
+
19
+ /** The report every check tool returns: what to rewrite, and the mechanical fixes. */
20
+ function report(subject, source, reviewed, { returnFixed = false } = {}) {
21
+ const { result, brief, mechanical, fixed } = reviewed;
22
+ const { metrics } = result;
23
+ const lines = [`Roughen checked ${subject}: ${metrics.wordCount} body words in ${metrics.sentenceCount} sentences${metrics.meanSentenceLength ? `, ${metrics.meanSentenceLength.toFixed(1)} words a sentence on average` : ''}.`];
24
+ if (!brief.needed && !mechanical.length) {
25
+ lines.push('', "Nothing to revise: no habit crossed Roughen's human-writing baselines.");
26
+ return text(lines.join('\n'));
27
+ }
28
+ if (brief.needed) lines.push('', brief.text);
29
+ if (mechanical.length) {
30
+ lines.push('', mechanicalSummary(mechanical));
31
+ if (fixed !== null && fixed !== source) {
32
+ lines.push(returnFixed ? `\nText with the mechanical fixes applied:\n\n${fixed}` : 'Call again with returnFixed: true to get the text with those fixes applied.');
33
+ }
34
+ }
35
+ if (brief.needed) lines.push('', 'After revising, call roughen_compare with the original and your revision to confirm the facts survived and the flagged habits dropped.');
36
+ return text(lines.join('\n'));
37
+ }
38
+
39
+ /**
40
+ * Roughen as an MCP server. Everything runs locally and deterministically:
41
+ * no network, no model calls, and no tool writes a file. The client's model
42
+ * does the rewriting; Roughen measures before and after.
43
+ */
44
+ export function createServer() {
45
+ const server = new McpServer({ name: 'roughen', version: serverVersion }, {
46
+ instructions: 'Roughen checks AI-written copy against measured human-writing baselines. Use roughen_check (or roughen_check_file) on a draft, revise only what the brief flags, then roughen_compare the original and your revision before using it. For a whole Next.js site, roughen_site plans the pass in groups of files and roughen_verify checks the edited files against git. It never rewrites text itself and makes no authorship claims.',
47
+ });
48
+
49
+ server.registerTool('roughen_check', {
50
+ title: 'Check copy',
51
+ description: 'Check a draft for the habits that make AI writing read as generated (comma-list saturation, "X, not Y" frames, hedging, em-dash and semicolon density, long or flat sentences, assistant framing, invisible characters). Returns a revision brief: which passages to rewrite, how many, why, and what must not change. Local and deterministic.',
52
+ inputSchema: {
53
+ text: z.string().min(1).describe('The copy to check.'),
54
+ format: proseFormat,
55
+ returnFixed: z.boolean().default(false).describe('Also return the text with safe mechanical fixes applied (spacing, invisible characters, assistant framing).'),
56
+ },
57
+ annotations: { title: 'Check copy', ...readOnly },
58
+ }, async ({ text: source, format, returnFixed }) => report('the text', source, review(source, { format }), { returnFixed }));
59
+
60
+ server.registerTool('roughen_check_file', {
61
+ title: 'Check a file',
62
+ description: 'Check the copy in a local file: Markdown, MDX, HTML or text, or the JSX text and copy props of a .tsx/.jsx/.ts/.js component. Uses the nearest roughen.config for project voice rules. Reads the file; never writes it.',
63
+ inputSchema: {
64
+ path: z.string().min(1).describe('Absolute path to the file.'),
65
+ returnFixed: z.boolean().default(false).describe('For prose files, also return the text with safe mechanical fixes applied.'),
66
+ },
67
+ annotations: { title: 'Check a file', ...readOnly },
68
+ }, async ({ path: file, returnFixed }) => {
69
+ if (!path.isAbsolute(file)) return failure('Give an absolute path; this server has no working directory of its own.');
70
+ const format = formatFor(file);
71
+ if (!format) return failure(`Roughen reads .md, .mdx, .html, .htm, .txt, .tsx, .jsx, .ts and .js files, not ${path.extname(file) || 'files without an extension'}.`);
72
+ let source;
73
+ try { source = await readFile(file, 'utf8'); } catch (error) { return failure(`Couldn't read ${file}: ${error.code ?? error.message}`); }
74
+ const loaded = await loadConfig(file);
75
+ if (!included(file, loaded)) return text(`${file} is excluded by ${path.join(loaded.dir, 'roughen.config')}'s include/exclude globs, so Roughen didn't check it.`);
76
+ try {
77
+ return report(path.basename(file), source, review(source, { file, format, config: loaded.config }), { returnFixed });
78
+ } catch (error) {
79
+ return failure(`Roughen couldn't read the copy in ${file}: ${error.message}`);
80
+ }
81
+ });
82
+
83
+ server.registerTool('roughen_compare', {
84
+ title: 'Verify a revision',
85
+ description: 'Compare an original draft with your revision. Reports whether every figure, link, code sample, quotation and heading survived, whether anything was added, whether the length stayed in bounds, and whether the flagged habits decreased, then says which version to use.',
86
+ inputSchema: {
87
+ original: z.string().min(1).describe('The draft before revision.'),
88
+ revised: z.string().min(1).describe('Your revision.'),
89
+ format: proseFormat,
90
+ },
91
+ annotations: { title: 'Verify a revision', ...readOnly },
92
+ }, async ({ original, revised, format }) => {
93
+ const judgment = judgeRevision(original, revised, { format });
94
+ const after = review(revised, { format });
95
+ const lines = [
96
+ judgment.accepted ? 'Use the revision.' : 'Keep the original, or fix the revision and compare again.',
97
+ '',
98
+ `Facts: ${judgment.preservation.ok ? 'every figure, link, code sample, quotation and heading survived, and nothing was added' : judgment.preservation.reasons.join(' ')}`,
99
+ `Length: ${judgment.preservation.lengthRatio.toFixed(2)}× the original's body text.`,
100
+ judgment.rules.length
101
+ ? `Flagged habits: ${judgment.flaggedBefore} before, ${judgment.flaggedAfter} after (${judgment.rules.join(', ')}).`
102
+ : 'Flagged habits: the original had none that asked for a rewrite.',
103
+ ];
104
+ if (after.brief.needed) lines.push('', 'Still flagged in the revision:', '', after.brief.text);
105
+ return text(lines.join('\n'));
106
+ });
107
+
108
+ server.registerTool('roughen_site', {
109
+ title: 'Plan a site copy pass',
110
+ description: 'For a Next.js app-router project: which copy each route ships (following imports and tsconfig paths), the findings per route and per file, copy no route ships, and a work plan of file groups with no overlap. Pass a group id to get that group\'s revision briefs instead. Protected strings (H1s, titles, FAQ questions, keywords, quotes) are marked and never offered for rewriting. Reads files; never writes.',
111
+ inputSchema: {
112
+ path: z.string().min(1).describe('Absolute path to the project root (the directory holding app/ or src/app/).'),
113
+ group: z.string().optional().describe('A work-group id from the plan, such as "services-2". Returns that group\'s files with a revision brief for each.'),
114
+ groupWords: z.number().int().min(100).default(6000).describe('Editable words per work group.'),
115
+ },
116
+ annotations: { title: 'Plan a site copy pass', ...readOnly },
117
+ }, async ({ path: root, group, groupWords }) => {
118
+ if (!path.isAbsolute(root)) return failure('Give an absolute path; this server has no working directory of its own.');
119
+ let site;
120
+ try { site = await planSite(root, { groupWords }); } catch (error) { return failure(error.message); }
121
+ if (!group) return text(`${renderPlan(site)}\nCall roughen_site again with a group id to get its revision briefs. After editing a group, call roughen_verify on the project.`);
122
+ const found = site.plan.find((item) => item.id === group);
123
+ if (!found) return failure(`No work group "${group}". Groups: ${site.plan.map((item) => item.id).join(', ')}.`);
124
+ return text(`${renderGroupBrief(site, found)}\nWhen you're done, call roughen_verify on ${root} to check the edits kept their facts and left the code alone.`);
125
+ });
126
+
127
+ server.registerTool('roughen_verify', {
128
+ title: 'Verify copy edits against git',
129
+ description: 'Compare every changed file under a path with its version at a git commit (default HEAD). Fails on code changes, files that no longer parse, edited protected strings (H1s, titles, FAQ questions, keywords, quotes), lost or invented numbers and links, new banned characters, rewritten copy beyond 15% of its length, and habits that grew. Read-only: it reads files and asks git for the old versions.',
130
+ inputSchema: {
131
+ path: z.string().min(1).describe('Absolute path to a file or directory inside a git repository.'),
132
+ ref: z.string().min(1).default('HEAD').describe('The commit to compare against.'),
133
+ },
134
+ annotations: { title: 'Verify copy edits against git', ...readOnly },
135
+ }, async ({ path: target, ref }) => {
136
+ if (!path.isAbsolute(target)) return failure('Give an absolute path; this server has no working directory of its own.');
137
+ let result;
138
+ try { result = await verifyGit({ paths: [target], ref }); } catch (error) { return failure(error.message); }
139
+ return text(`${result.ok ? 'Verified: no errors.' : 'Not verified: fix the errors below and verify again.'}\n\n${renderVerify(result)}`);
140
+ });
141
+
142
+ server.registerPrompt('site_pass', {
143
+ title: 'Revise a site\'s copy',
144
+ description: 'Revise one work group of a Next.js site\'s copy so it reads like a person wrote it, then verify the edits.',
145
+ argsSchema: { path: z.string().describe('Absolute path to the project root.'), group: z.string().optional().describe('The work group to take. Leave empty to plan first.') },
146
+ }, ({ path: root, group }) => ({
147
+ messages: [{
148
+ role: 'user',
149
+ content: {
150
+ type: 'text',
151
+ text: `Revise the copy of the Next.js site at ${root} so it reads like a person wrote it.
152
+
153
+ 1. ${group ? `Call roughen_site with path ${root} and group "${group}".` : `Call roughen_site with path ${root} to see the plan, pick a work group, then call it again with that group id.`}
154
+ 2. Edit only the files in that group, and only the text inside strings and JSX. Never change keys, imports, class names, links or the number of items. Leave protected strings exactly as written: H1s, titles and headlines, FAQ questions, keywords, schema names, quotations and citations.
155
+ 3. Fix what each brief asks for. Keep every number, price, date, name and link exactly as written, don't add claims, and keep each string within about 15% of its length.
156
+ 4. Call roughen_verify with path ${root}. Fix every error it names and verify again, at most twice.
157
+ 5. Report the files you changed, what verify said, and anything you left alone on purpose.`,
158
+ },
159
+ }],
160
+ }));
161
+
162
+ server.registerPrompt('humanize', {
163
+ title: 'Make copy read human',
164
+ description: 'Revise copy so it reads like a person wrote it, using Roughen to check and verify.',
165
+ argsSchema: { text: z.string().optional().describe('The copy to revise. Leave empty to revise the copy in the conversation.') },
166
+ }, ({ text: source }) => {
167
+ const habits = listRules().filter((rule) => rule.severity !== 'info').map((rule) => `- ${rule.id}: ${rule.description}`).join('\n');
168
+ return {
169
+ messages: [{
170
+ role: 'user',
171
+ content: {
172
+ type: 'text',
173
+ text: `Revise this copy so it reads like a person wrote it.
174
+
175
+ 1. Call roughen_check on it.
176
+ 2. Rewrite only the passages the brief flags, as many as it asks. Keep every number, price, date, name, link, quotation and code sample exactly as written. Don't add facts or examples; if a hedge hides a specific you don't have, cut the hedge.
177
+ 3. Call roughen_compare with the original and your revision. If it says to keep the original, fix what it names and compare again, at most twice.
178
+ 4. Return the version roughen_compare accepted, and say in one line what changed.
179
+
180
+ Roughen checks these habits against 110 human-written documents:
181
+ ${habits}
182
+ ${source ? `\nThe copy:\n\n${source}` : ''}`,
183
+ },
184
+ }],
185
+ };
186
+ });
187
+
188
+ return server;
189
+ }
190
+
191
+ export { coreVersion };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@roughen/mcp",
3
+ "version": "0.2.0",
4
+ "description": "Local MCP server that checks AI writing against human baselines and verifies rewrites. No network, no model calls.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "prose",
10
+ "linter",
11
+ "writing",
12
+ "ai-writing",
13
+ "copy"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "engines": {
20
+ "node": ">=22.13"
21
+ },
22
+ "bin": {
23
+ "roughen-mcp": "./bin/roughen-mcp.mjs"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "lib",
28
+ "LICENSE",
29
+ "README.md"
30
+ ],
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "1.30.0",
33
+ "zod": "^4.6.5",
34
+ "@roughen/cli": "^0.3.0",
35
+ "@roughen/core": "^0.3.0"
36
+ },
37
+ "scripts": {
38
+ "build": "node --check bin/roughen-mcp.mjs && node --check lib/server.mjs"
39
+ }
40
+ }