@hanna84/mcp-writing 3.0.0 → 3.1.1

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 CHANGED
@@ -4,11 +4,31 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
- #### [v3.0.0](https://github.com/hannasdev/mcp-writing.git
7
+ #### [v3.1.1](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v3.1.0...v3.1.1)
9
+
10
+ - fix: surface runtime warning for invalid PROSE_STYLEGUIDE_ENFORCEMENT_MODE [`#167`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/167)
12
+
13
+ #### [v3.1.0](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v3.0.0...v3.1.0)
15
+
16
+ > 2 May 2026
17
+
18
+ - feat(editing): enforce styleguide automatically in edit flow [`#166`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/166)
20
+ - Release 3.1.0 [`ad1106c`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/ad1106ca4099777d9b05556dfd9cf75de4f030f1)
22
+
23
+ ### [v3.0.0](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v2.18.1...v3.0.0)
9
25
 
26
+ > 2 May 2026
27
+
10
28
  - feat!: improve MCP tooling usability and harden scene-id safety [`#165`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/165)
30
+ - Release 3.0.0 [`d7541b1`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/d7541b1511dbe92c514cb0435f94b8626be5ea29)
12
32
 
13
33
  #### [v2.18.1](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v2.18.0...v2.18.1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -71,7 +71,14 @@ const OWNERSHIP_GUARD_MODE_RAW = (process.env.OWNERSHIP_GUARD_MODE ?? "warn").tr
71
71
  const OWNERSHIP_GUARD_MODE = OWNERSHIP_GUARD_MODE_RAW === "fail" || OWNERSHIP_GUARD_MODE_RAW === "warn"
72
72
  ? OWNERSHIP_GUARD_MODE_RAW
73
73
  : "warn";
74
+ const STYLEGUIDE_ENFORCEMENT_MODE_RAW = (process.env.PROSE_STYLEGUIDE_ENFORCEMENT_MODE ?? "warn").trim().toLowerCase();
75
+ const STYLEGUIDE_ENFORCEMENT_MODE = STYLEGUIDE_ENFORCEMENT_MODE_RAW === "off"
76
+ || STYLEGUIDE_ENFORCEMENT_MODE_RAW === "warn"
77
+ || STYLEGUIDE_ENFORCEMENT_MODE_RAW === "required"
78
+ ? STYLEGUIDE_ENFORCEMENT_MODE_RAW
79
+ : "warn";
74
80
  const OWNERSHIP_GUARD_MODE_RAW_DISPLAY = JSON.stringify(OWNERSHIP_GUARD_MODE_RAW);
81
+ const STYLEGUIDE_ENFORCEMENT_MODE_RAW_DISPLAY = JSON.stringify(STYLEGUIDE_ENFORCEMENT_MODE_RAW);
75
82
  const __filename = fileURLToPath(import.meta.url);
76
83
  const __dirname = path.dirname(__filename);
77
84
  const ROOT_DIR = path.resolve(__dirname, "..");
@@ -213,6 +220,9 @@ const RUNTIME_DIAGNOSTICS = getRuntimeDiagnostics({
213
220
  ownershipGuardModeRaw: OWNERSHIP_GUARD_MODE_RAW,
214
221
  ownershipGuardMode: OWNERSHIP_GUARD_MODE,
215
222
  ownershipGuardModeRawDisplay: OWNERSHIP_GUARD_MODE_RAW_DISPLAY,
223
+ styleguideEnforcementModeRaw: STYLEGUIDE_ENFORCEMENT_MODE_RAW,
224
+ styleguideEnforcementMode: STYLEGUIDE_ENFORCEMENT_MODE,
225
+ styleguideEnforcementModeRawDisplay: STYLEGUIDE_ENFORCEMENT_MODE_RAW_DISPLAY,
216
226
  syncDirWritable: SYNC_DIR_WRITABLE,
217
227
  syncDirAbs: SYNC_DIR_ABS,
218
228
  syncOwnershipDiagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
@@ -410,6 +420,7 @@ function createMcpServer() {
410
420
  isPathCandidateInsideSyncDir,
411
421
  pendingProposals,
412
422
  generateProposalId,
423
+ STYLEGUIDE_ENFORCEMENT_MODE,
413
424
  };
414
425
  registerSyncTools(s, toolContext);
415
426
  registerSearchTools(s, toolContext);
@@ -434,6 +445,7 @@ function createMcpServer() {
434
445
  permission_diagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
435
446
  git_available: GIT_AVAILABLE,
436
447
  git_enabled: GIT_ENABLED,
448
+ styleguide_enforcement_mode: STYLEGUIDE_ENFORCEMENT_MODE,
437
449
  http_port: HTTP_PORT,
438
450
  runtime_warnings: RUNTIME_DIAGNOSTICS.warnings,
439
451
  setup_recommendations: RUNTIME_DIAGNOSTICS.recommendations,
@@ -6,9 +6,12 @@
6
6
  * is straightforward to test.
7
7
  *
8
8
  * @param {object} opts
9
- * @param {string} opts.ownershipGuardModeRaw Raw env value before normalisation
9
+ * @param {string} opts.ownershipGuardModeRaw Trimmed/lowercased env token before enum validation
10
10
  * @param {string} opts.ownershipGuardMode Normalised value ("warn" | "fail")
11
11
  * @param {string} opts.ownershipGuardModeRawDisplay JSON.stringify of the raw value
12
+ * @param {string} opts.styleguideEnforcementModeRaw Trimmed/lowercased env token before enum validation
13
+ * @param {string} opts.styleguideEnforcementMode Normalised value ("off" | "warn" | "required")
14
+ * @param {string} opts.styleguideEnforcementModeRawDisplay JSON.stringify of the raw value
12
15
  * @param {boolean} opts.syncDirWritable
13
16
  * @param {string} opts.syncDirAbs Resolved absolute path shown in messages
14
17
  * @param {object} opts.syncOwnershipDiagnostics Result of getSyncOwnershipDiagnostics()
@@ -20,6 +23,9 @@ export function getRuntimeDiagnostics({
20
23
  ownershipGuardModeRaw,
21
24
  ownershipGuardMode,
22
25
  ownershipGuardModeRawDisplay,
26
+ styleguideEnforcementModeRaw,
27
+ styleguideEnforcementMode,
28
+ styleguideEnforcementModeRawDisplay,
23
29
  syncDirWritable,
24
30
  syncDirAbs,
25
31
  syncOwnershipDiagnostics,
@@ -36,6 +42,13 @@ export function getRuntimeDiagnostics({
36
42
  recommendations.push("Set OWNERSHIP_GUARD_MODE to either 'warn' or 'fail'.");
37
43
  }
38
44
 
45
+ if (styleguideEnforcementModeRaw !== styleguideEnforcementMode) {
46
+ warnings.push(
47
+ `STYLEGUIDE_ENFORCEMENT_MODE_INVALID: Unsupported PROSE_STYLEGUIDE_ENFORCEMENT_MODE=${styleguideEnforcementModeRawDisplay}. Falling back to 'warn'.`
48
+ );
49
+ recommendations.push("Set PROSE_STYLEGUIDE_ENFORCEMENT_MODE to one of 'off', 'warn', or 'required'.");
50
+ }
51
+
39
52
  if (syncOwnershipDiagnostics.runtime_uid_override_ignored) {
40
53
  warnings.push("RUNTIME_UID_OVERRIDE_IGNORED: RUNTIME_UID_OVERRIDE is ignored unless NODE_ENV=test or ALLOW_RUNTIME_UID_OVERRIDE=1.");
41
54
  recommendations.push("Avoid RUNTIME_UID_OVERRIDE in production runtime environments.");
@@ -1,9 +1,17 @@
1
1
  import { z } from "zod";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
3
5
  import matter from "gray-matter";
4
6
  import yaml from "js-yaml";
5
7
  import { createSnapshot, listSnapshots } from "../core/git.js";
6
8
  import { getFileWriteDiagnostics, readMeta, indexSceneFile } from "../sync/sync.js";
9
+ import { resolveStyleguideConfig } from "../styleguide/prose-styleguide.js";
10
+ import {
11
+ PROSE_STYLEGUIDE_SKILL_BASENAME,
12
+ PROSE_STYLEGUIDE_SKILL_DIRNAME,
13
+ } from "../styleguide/prose-styleguide-skill.js";
14
+ import { analyzeSceneStyleguideDrift } from "../styleguide/prose-styleguide-drift.js";
7
15
 
8
16
  function renderSceneContent(metadata, revisedProse) {
9
17
  const hasFrontmatter = metadata && Object.keys(metadata).length > 0;
@@ -13,10 +21,197 @@ function renderSceneContent(metadata, revisedProse) {
13
21
  : `${normalizedProse}\n`;
14
22
  }
15
23
 
24
+ function digestFor(value) {
25
+ return createHash("sha256").update(value).digest("hex");
26
+ }
27
+
28
+ function buildStyleguideFingerprint({
29
+ resolvedConfig,
30
+ sources,
31
+ skillDigest,
32
+ setupRequired,
33
+ }) {
34
+ return digestFor(JSON.stringify({
35
+ resolved_config: resolvedConfig ?? null,
36
+ sources: sources ?? [],
37
+ skill_digest: skillDigest ?? null,
38
+ setup_required: Boolean(setupRequired),
39
+ }));
40
+ }
41
+
42
+ function resolveStyleguideSnapshot({ syncDir, projectId, errorResponse }) {
43
+ const resolved = resolveStyleguideConfig({
44
+ syncDir,
45
+ projectId,
46
+ });
47
+ if (!resolved.ok) {
48
+ return {
49
+ ok: false,
50
+ response: errorResponse(
51
+ resolved.error.code,
52
+ resolved.error.message,
53
+ resolved.error.details
54
+ ),
55
+ };
56
+ }
57
+
58
+ const skillPath = path.join(
59
+ syncDir,
60
+ PROSE_STYLEGUIDE_SKILL_DIRNAME,
61
+ PROSE_STYLEGUIDE_SKILL_BASENAME
62
+ );
63
+ let skillExists;
64
+ let skillDigest = null;
65
+ try {
66
+ skillExists = fs.existsSync(skillPath);
67
+ if (skillExists) {
68
+ skillDigest = digestFor(fs.readFileSync(skillPath, "utf8"));
69
+ }
70
+ } catch (error) {
71
+ const err = error instanceof Error ? error : new Error(String(error));
72
+ return {
73
+ ok: false,
74
+ response: errorResponse(
75
+ "STYLEGUIDE_SKILL_IO_ERROR",
76
+ "Failed to read skills/prose-styleguide.md while resolving styleguide snapshot.",
77
+ {
78
+ file_path: skillPath,
79
+ reason: err.message,
80
+ }
81
+ ),
82
+ };
83
+ }
84
+ const fingerprint = buildStyleguideFingerprint({
85
+ resolvedConfig: resolved.resolved_config,
86
+ sources: resolved.sources,
87
+ skillDigest,
88
+ setupRequired: resolved.setup_required,
89
+ });
90
+
91
+ return {
92
+ ok: true,
93
+ snapshot: {
94
+ project_id: projectId ?? null,
95
+ config_found: resolved.config_found,
96
+ setup_required: resolved.setup_required,
97
+ sources: resolved.sources,
98
+ resolved_config: resolved.resolved_config,
99
+ skill_file_path: skillPath,
100
+ skill_found: Boolean(skillExists),
101
+ skill_digest: skillDigest,
102
+ fingerprint,
103
+ },
104
+ };
105
+ }
106
+
107
+ function evaluateStyleguidePolicy({
108
+ snapshot,
109
+ revisedProse,
110
+ bypassStyleguide,
111
+ bypassReason,
112
+ enforcementMode,
113
+ errorResponse,
114
+ }) {
115
+ if (bypassStyleguide && !bypassReason) {
116
+ return {
117
+ ok: false,
118
+ response: errorResponse(
119
+ "STYLEGUIDE_BYPASS_REASON_REQUIRED",
120
+ "bypass_reason is required when bypass_styleguide=true."
121
+ ),
122
+ };
123
+ }
124
+
125
+ const warnings = [];
126
+ if (enforcementMode !== "off") {
127
+ if (snapshot.setup_required || !snapshot.config_found) {
128
+ if (enforcementMode === "required" && !bypassStyleguide) {
129
+ return {
130
+ ok: false,
131
+ response: errorResponse(
132
+ "STYLEGUIDE_CONFIG_REQUIRED",
133
+ "Cannot propose prose edits before prose-styleguide.config.yaml is configured.",
134
+ {
135
+ project_id: snapshot.project_id,
136
+ next_step: "Run setup_prose_styleguide_config (or bootstrap_prose_styleguide_config), then retry propose_edit.",
137
+ }
138
+ ),
139
+ };
140
+ }
141
+ warnings.push("No prose-styleguide.config.yaml is currently resolved for this scene.");
142
+ }
143
+
144
+ if (!snapshot.skill_found) {
145
+ if (enforcementMode === "required" && !bypassStyleguide) {
146
+ return {
147
+ ok: false,
148
+ response: errorResponse(
149
+ "STYLEGUIDE_SKILL_REQUIRED",
150
+ "Cannot propose prose edits before skills/prose-styleguide.md exists.",
151
+ {
152
+ next_step: "Run setup_prose_styleguide_skill, then retry propose_edit.",
153
+ }
154
+ ),
155
+ };
156
+ }
157
+ warnings.push("skills/prose-styleguide.md was not found at sync root.");
158
+ }
159
+ }
160
+
161
+ const canApply =
162
+ enforcementMode !== "off"
163
+ && !bypassStyleguide
164
+ && snapshot.config_found
165
+ && !snapshot.setup_required
166
+ && Boolean(snapshot.resolved_config);
167
+
168
+ const analysis = canApply
169
+ ? analyzeSceneStyleguideDrift({
170
+ prose: revisedProse,
171
+ resolvedConfig: snapshot.resolved_config,
172
+ })
173
+ : { observed: {}, drift: [] };
174
+
175
+ const violations = analysis.drift.map((entry) => ({
176
+ field: entry.field,
177
+ declared: entry.declared,
178
+ observed: entry.observed,
179
+ severity: "error",
180
+ }));
181
+
182
+ if (enforcementMode === "required" && violations.length > 0 && !bypassStyleguide) {
183
+ return {
184
+ ok: false,
185
+ response: errorResponse(
186
+ "STYLEGUIDE_VIOLATIONS",
187
+ "Revised prose violates required styleguide conventions.",
188
+ {
189
+ violations,
190
+ next_step: "Revise prose to match conventions or retry with bypass_styleguide=true and a bypass_reason.",
191
+ }
192
+ ),
193
+ };
194
+ }
195
+
196
+ return {
197
+ ok: true,
198
+ policy: {
199
+ enforcement_mode: enforcementMode,
200
+ bypass_used: Boolean(bypassStyleguide),
201
+ bypass_reason: bypassStyleguide ? bypassReason : null,
202
+ styleguide_applied: canApply,
203
+ warnings,
204
+ observed_signals: analysis.observed,
205
+ violations,
206
+ },
207
+ };
208
+ }
209
+
16
210
  export function registerEditingTools(s, {
17
211
  db,
18
212
  SYNC_DIR,
19
213
  GIT_ENABLED,
214
+ STYLEGUIDE_ENFORCEMENT_MODE,
20
215
  errorResponse,
21
216
  jsonResponse,
22
217
  pendingProposals,
@@ -31,8 +226,10 @@ export function registerEditingTools(s, {
31
226
  project_id: z.string().optional().describe("Optional project ID to disambiguate duplicate scene IDs across projects."),
32
227
  instruction: z.string().describe("A brief instruction for the edit (e.g. 'Tighten the opening paragraph'). Used in the git commit message."),
33
228
  revised_prose: z.string().describe("The complete revised prose text for the scene."),
229
+ bypass_styleguide: z.boolean().optional().describe("If true, bypasses automatic styleguide checks for this proposal."),
230
+ bypass_reason: z.string().optional().describe("Required when bypass_styleguide=true. Explains why this edit should ignore styleguide checks."),
34
231
  },
35
- async ({ scene_id, project_id, instruction, revised_prose }) => {
232
+ async ({ scene_id, project_id, instruction, revised_prose, bypass_styleguide = false, bypass_reason }) => {
36
233
  if (!GIT_ENABLED) {
37
234
  return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported. Ensure git is installed and the sync directory is writable.");
38
235
  }
@@ -58,6 +255,27 @@ export function registerEditingTools(s, {
58
255
  scene = scenes[0];
59
256
  }
60
257
 
258
+ const styleguideSnapshotResult = resolveStyleguideSnapshot({
259
+ syncDir: SYNC_DIR,
260
+ projectId: scene.project_id,
261
+ errorResponse,
262
+ });
263
+ if (!styleguideSnapshotResult.ok) {
264
+ return styleguideSnapshotResult.response;
265
+ }
266
+
267
+ const styleguidePolicy = evaluateStyleguidePolicy({
268
+ snapshot: styleguideSnapshotResult.snapshot,
269
+ revisedProse: revised_prose,
270
+ bypassStyleguide: bypass_styleguide,
271
+ bypassReason: bypass_reason,
272
+ enforcementMode: STYLEGUIDE_ENFORCEMENT_MODE,
273
+ errorResponse,
274
+ });
275
+ if (!styleguidePolicy.ok) {
276
+ return styleguidePolicy.response;
277
+ }
278
+
61
279
  try {
62
280
  const raw = fs.readFileSync(scene.file_path, "utf8");
63
281
  const { data: metadata, content: currentProse } = matter(raw);
@@ -90,6 +308,8 @@ export function registerEditingTools(s, {
90
308
  rendered_content: renderedContent,
91
309
  original_prose: currentProse,
92
310
  metadata,
311
+ styleguide_snapshot: styleguideSnapshotResult.snapshot,
312
+ styleguide_policy: styleguidePolicy.policy,
93
313
  created_at: new Date().toISOString(),
94
314
  });
95
315
 
@@ -107,6 +327,13 @@ export function registerEditingTools(s, {
107
327
  instruction,
108
328
  diff_preview: diffPreview,
109
329
  noop,
330
+ styleguide: {
331
+ ...styleguidePolicy.policy,
332
+ fingerprint: styleguideSnapshotResult.snapshot.fingerprint,
333
+ config_found: styleguideSnapshotResult.snapshot.config_found,
334
+ skill_found: styleguideSnapshotResult.snapshot.skill_found,
335
+ sources: styleguideSnapshotResult.snapshot.sources,
336
+ },
110
337
  note: noop
111
338
  ? "This proposal matches the current scene file. Calling commit_edit will be a no-op."
112
339
  : "Review the diff above. Call commit_edit with this proposal_id to apply the change.",
@@ -167,6 +394,35 @@ export function registerEditingTools(s, {
167
394
  return errorResponse("INVALID_EDIT", `Proposal '${proposal_id}' is for project '${proposal.project_id}', not '${project_id}'.`);
168
395
  }
169
396
 
397
+ if (
398
+ proposal.styleguide_snapshot
399
+ && proposal.styleguide_policy
400
+ && proposal.styleguide_policy.bypass_used !== true
401
+ ) {
402
+ const latestStyleguideSnapshot = resolveStyleguideSnapshot({
403
+ syncDir: SYNC_DIR,
404
+ projectId: proposal.project_id,
405
+ errorResponse,
406
+ });
407
+ if (!latestStyleguideSnapshot.ok) {
408
+ return latestStyleguideSnapshot.response;
409
+ }
410
+
411
+ if (latestStyleguideSnapshot.snapshot.fingerprint !== proposal.styleguide_snapshot.fingerprint) {
412
+ return errorResponse(
413
+ "STYLEGUIDE_CHANGED_SINCE_PROPOSAL",
414
+ "Styleguide inputs changed after this proposal was created. Re-run propose_edit against the latest styleguide before commit.",
415
+ {
416
+ proposal_id,
417
+ project_id: proposal.project_id ?? null,
418
+ previous_fingerprint: proposal.styleguide_snapshot.fingerprint,
419
+ current_fingerprint: latestStyleguideSnapshot.snapshot.fingerprint,
420
+ next_step: "Call propose_edit again to regenerate this revision with current styleguide rules.",
421
+ }
422
+ );
423
+ }
424
+ }
425
+
170
426
  try {
171
427
  const proseWriteDiagnostics = getFileWriteDiagnostics(proposal.scene_file_path);
172
428
  if (proseWriteDiagnostics.stat_error_code === "EACCES" || proseWriteDiagnostics.stat_error_code === "EPERM") {