@hanna84/mcp-writing 2.9.5 → 2.9.6

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,21 @@ 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
+ #### [v2.9.6](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.9.5...v2.9.6)
9
+
10
+ - refactor(tools): extract registerReviewBundleTools into tools/review-bundles.js [`#99`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/99)
12
+
7
13
  #### [v2.9.5](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v2.9.4...v2.9.5)
9
15
 
16
+ > 26 April 2026
17
+
10
18
  - refactor(tools): extract registerMetadataTools into tools/metadata.js [`#98`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/98)
20
+ - Release 2.9.5 [`85c4bd5`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/85c4bd5db34bc51c92a69be7af6285f8934959b7)
12
22
 
13
23
  #### [v2.9.4](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v2.9.3...v2.9.4)
package/index.js CHANGED
@@ -13,7 +13,7 @@ import yaml from "js-yaml";
13
13
  import { z } from "zod";
14
14
  import { openDb } from "./db.js";
15
15
  import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, getFileWriteDiagnostics, readMeta, indexSceneFile, sidecarPath, isStructuralProjectId } from "./sync.js";
16
- import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit, getHeadCommitHash } from "./git.js";
16
+ import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
17
17
  import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
18
18
  import { validateProjectId } from "./importer.js";
19
19
  import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
@@ -36,16 +36,11 @@ import {
36
36
  PROSE_STYLEGUIDE_SKILL_DIRNAME,
37
37
  buildProseStyleguideSkill,
38
38
  } from "./prose-styleguide-skill.js";
39
- import {
40
- REVIEW_BUNDLE_PROFILES,
41
- REVIEW_BUNDLE_STRICTNESS,
42
- ReviewBundlePlanError,
43
- buildReviewBundlePlan,
44
- createReviewBundleArtifacts,
45
- } from "./review-bundles.js";
39
+ import { ReviewBundlePlanError } from "./review-bundles.js";
46
40
  import { registerSyncTools } from "./tools/sync.js";
47
41
  import { registerSearchTools } from "./tools/search.js";
48
42
  import { registerMetadataTools } from "./tools/metadata.js";
43
+ import { registerReviewBundleTools } from "./tools/review-bundles.js";
49
44
 
50
45
  const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
51
46
  const DB_PATH = process.env.DB_PATH ?? "./writing.db";
@@ -1043,10 +1038,12 @@ function createMcpServer() {
1043
1038
  readSupportingNotesForEntity,
1044
1039
  readEntityMetadata,
1045
1040
  createCanonicalWorldEntity,
1041
+ resolveOutputDirWithinSync,
1046
1042
  };
1047
1043
  registerSyncTools(s, toolContext);
1048
1044
  registerSearchTools(s, toolContext);
1049
1045
  registerMetadataTools(s, toolContext);
1046
+ registerReviewBundleTools(s, toolContext);
1050
1047
 
1051
1048
  // ---- get_runtime_config --------------------------------------------------
1052
1049
  s.tool(
@@ -1720,192 +1717,6 @@ function createMcpServer() {
1720
1717
  }
1721
1718
  );
1722
1719
 
1723
- // ---- preview_review_bundle ----------------------------------------------
1724
- s.tool(
1725
- "preview_review_bundle",
1726
- "Dry-run planning tool for review bundles. Resolves scene scope, deterministic ordering, warnings, and planned output filenames without writing files. Rendering options are accepted for API consistency and reflected in resolved_scope.options, but do not change planning output.",
1727
- {
1728
- project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
1729
- profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
1730
- part: z.number().int().optional().describe("Optional part filter."),
1731
- chapter: z.number().int().optional().describe("Optional chapter filter."),
1732
- tag: z.string().optional().describe("Optional tag filter (exact match)."),
1733
- scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
1734
- strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
1735
- include_scene_ids: z.boolean().optional().describe("Rendering option (default true). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
1736
- include_metadata_sidebar: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
1737
- include_paragraph_anchors: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
1738
- recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
1739
- bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in planned outputs)."),
1740
- format: z.enum(["pdf", "markdown", "both"]).optional().describe("Planned output format: pdf (default), markdown, or both. Affects planned_outputs filenames only; preview_review_bundle does not render artifacts."),
1741
- },
1742
- async ({
1743
- project_id,
1744
- profile,
1745
- part,
1746
- chapter,
1747
- tag,
1748
- scene_ids,
1749
- strictness = "warn",
1750
- include_scene_ids = true,
1751
- include_metadata_sidebar = false,
1752
- include_paragraph_anchors = false,
1753
- recipient_name,
1754
- bundle_name,
1755
- format = "pdf",
1756
- }) => {
1757
- const projectIdCheck = validateProjectId(project_id);
1758
- if (!projectIdCheck.ok) {
1759
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1760
- }
1761
-
1762
- try {
1763
- const plan = buildReviewBundlePlan(db, {
1764
- project_id,
1765
- profile,
1766
- part,
1767
- chapter,
1768
- tag,
1769
- scene_ids,
1770
- strictness,
1771
- include_scene_ids,
1772
- include_metadata_sidebar,
1773
- include_paragraph_anchors,
1774
- recipient_name,
1775
- bundle_name,
1776
- format,
1777
- });
1778
- return jsonResponse(plan);
1779
- } catch (error) {
1780
- if (error instanceof ReviewBundlePlanError) {
1781
- return errorResponse(error.code, error.message, error.details);
1782
- }
1783
- return errorResponse(
1784
- "PREVIEW_FAILED",
1785
- error instanceof Error ? error.message : "Failed to generate review bundle preview."
1786
- );
1787
- }
1788
- }
1789
- );
1790
-
1791
- // ---- create_review_bundle -----------------------------------------------
1792
- s.tool(
1793
- "create_review_bundle",
1794
- "Generate review bundle artifacts (PDF/markdown) from planned scene scope. Writes files only under output_dir and returns manifest/provenance details.",
1795
- {
1796
- project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
1797
- profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
1798
- output_dir: z.string().describe("Directory path to write bundle artifacts into."),
1799
- part: z.number().int().optional().describe("Optional part filter."),
1800
- chapter: z.number().int().optional().describe("Optional chapter filter."),
1801
- tag: z.string().optional().describe("Optional tag filter (exact match)."),
1802
- scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
1803
- strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
1804
- include_scene_ids: z.boolean().optional().describe("Include scene IDs in headings (default true). Applies to both PDF and markdown."),
1805
- include_metadata_sidebar: z.boolean().optional().describe("Include metadata sidebar in markdown output (default false). Markdown only — no effect on PDF."),
1806
- include_paragraph_anchors: z.boolean().optional().describe("Include paragraph anchors in markdown output (default false). Markdown only — no effect on PDF."),
1807
- recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
1808
- bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in filenames)."),
1809
- source_commit: z.string().optional().describe("Optional explicit source commit for provenance. Defaults to current HEAD when available."),
1810
- format: z.enum(["pdf", "markdown", "both"]).optional().describe("Output format: pdf (default), markdown, or both."),
1811
- },
1812
- async ({
1813
- project_id,
1814
- profile,
1815
- output_dir,
1816
- part,
1817
- chapter,
1818
- tag,
1819
- scene_ids,
1820
- strictness = "warn",
1821
- include_scene_ids = true,
1822
- include_metadata_sidebar = false,
1823
- include_paragraph_anchors = false,
1824
- recipient_name,
1825
- bundle_name,
1826
- source_commit,
1827
- format = "pdf",
1828
- }) => {
1829
- const projectIdCheck = validateProjectId(project_id);
1830
- if (!projectIdCheck.ok) {
1831
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1832
- }
1833
-
1834
- try {
1835
- const { resolvedOutputDir, relativeToSyncDir } = resolveOutputDirWithinSync(output_dir);
1836
- const outputDirSegments = relativeToSyncDir
1837
- .split(path.sep)
1838
- .filter(Boolean)
1839
- .map(segment => segment.toLowerCase());
1840
- if (outputDirSegments.includes("scenes")) {
1841
- return errorResponse(
1842
- "INVALID_OUTPUT_DIR",
1843
- "output_dir cannot be inside a scenes directory. Choose a dedicated export folder under WRITING_SYNC_DIR.",
1844
- { output_dir: resolvedOutputDir }
1845
- );
1846
- }
1847
-
1848
- const plan = buildReviewBundlePlan(db, {
1849
- project_id,
1850
- profile,
1851
- part,
1852
- chapter,
1853
- tag,
1854
- scene_ids,
1855
- strictness,
1856
- include_scene_ids,
1857
- include_metadata_sidebar,
1858
- include_paragraph_anchors,
1859
- recipient_name,
1860
- bundle_name,
1861
- format,
1862
- });
1863
-
1864
- if (!plan.strictness_result.can_proceed) {
1865
- return errorResponse(
1866
- "STRICTNESS_BLOCKED",
1867
- "Bundle generation blocked by strictness policy.",
1868
- { strictness_result: plan.strictness_result, warning_summary: plan.warning_summary }
1869
- );
1870
- }
1871
-
1872
- const provenanceCommit = source_commit ?? (GIT_ENABLED ? getHeadCommitHash(SYNC_DIR) : null);
1873
- const artifacts = await createReviewBundleArtifacts(db, {
1874
- plan,
1875
- output_dir: resolvedOutputDir,
1876
- source_commit: provenanceCommit,
1877
- syncDir: SYNC_DIR_ABS,
1878
- });
1879
-
1880
- return jsonResponse({
1881
- ok: true,
1882
- bundle_id: artifacts.bundle_id,
1883
- output_paths: artifacts.output_paths,
1884
- summary: {
1885
- scene_count: plan.summary.scene_count,
1886
- profile: plan.profile,
1887
- applied_filters: plan.resolved_scope.filters,
1888
- },
1889
- warnings: plan.warnings,
1890
- warning_summary: plan.warning_summary,
1891
- provenance: {
1892
- source_commit: provenanceCommit,
1893
- generated_at: artifacts.generated_at,
1894
- project_id: plan.resolved_scope.project_id,
1895
- },
1896
- });
1897
- } catch (error) {
1898
- if (error instanceof ReviewBundlePlanError) {
1899
- return errorResponse(error.code, error.message, error.details);
1900
- }
1901
- return errorResponse(
1902
- "CREATE_BUNDLE_FAILED",
1903
- error instanceof Error ? error.message : "Failed to create review bundle artifacts."
1904
- );
1905
- }
1906
- }
1907
- );
1908
-
1909
1720
  // ---- PHASE 3: Prose Editing (git-backed) --------------------------------
1910
1721
 
1911
1722
  // ---- propose_edit --------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.9.5",
3
+ "version": "2.9.6",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,207 @@
1
+ import { z } from "zod";
2
+ import path from "node:path";
3
+ import {
4
+ REVIEW_BUNDLE_PROFILES,
5
+ REVIEW_BUNDLE_STRICTNESS,
6
+ ReviewBundlePlanError,
7
+ buildReviewBundlePlan,
8
+ createReviewBundleArtifacts,
9
+ } from "../review-bundles.js";
10
+ import { validateProjectId } from "../importer.js";
11
+ import { getHeadCommitHash } from "../git.js";
12
+
13
+ export function registerReviewBundleTools(s, {
14
+ db,
15
+ SYNC_DIR,
16
+ SYNC_DIR_ABS,
17
+ GIT_ENABLED,
18
+ errorResponse,
19
+ jsonResponse,
20
+ resolveOutputDirWithinSync,
21
+ }) {
22
+ // ---- preview_review_bundle ----------------------------------------------
23
+ s.tool(
24
+ "preview_review_bundle",
25
+ "Dry-run planning tool for review bundles. Resolves scene scope, deterministic ordering, warnings, and planned output filenames without writing files. Rendering options are accepted for API consistency and reflected in resolved_scope.options, but do not change planning output.",
26
+ {
27
+ project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
28
+ profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
29
+ part: z.number().int().optional().describe("Optional part filter."),
30
+ chapter: z.number().int().optional().describe("Optional chapter filter."),
31
+ tag: z.string().optional().describe("Optional tag filter (exact match)."),
32
+ scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
33
+ strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
34
+ include_scene_ids: z.boolean().optional().describe("Rendering option (default true). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
35
+ include_metadata_sidebar: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
36
+ include_paragraph_anchors: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
37
+ recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
38
+ bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in planned outputs)."),
39
+ format: z.enum(["pdf", "markdown", "both"]).optional().describe("Planned output format: pdf (default), markdown, or both. Affects planned_outputs filenames only; preview_review_bundle does not render artifacts."),
40
+ },
41
+ async ({
42
+ project_id,
43
+ profile,
44
+ part,
45
+ chapter,
46
+ tag,
47
+ scene_ids,
48
+ strictness = "warn",
49
+ include_scene_ids = true,
50
+ include_metadata_sidebar = false,
51
+ include_paragraph_anchors = false,
52
+ recipient_name,
53
+ bundle_name,
54
+ format = "pdf",
55
+ }) => {
56
+ const projectIdCheck = validateProjectId(project_id);
57
+ if (!projectIdCheck.ok) {
58
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
59
+ }
60
+
61
+ try {
62
+ const plan = buildReviewBundlePlan(db, {
63
+ project_id,
64
+ profile,
65
+ part,
66
+ chapter,
67
+ tag,
68
+ scene_ids,
69
+ strictness,
70
+ include_scene_ids,
71
+ include_metadata_sidebar,
72
+ include_paragraph_anchors,
73
+ recipient_name,
74
+ bundle_name,
75
+ format,
76
+ });
77
+ return jsonResponse(plan);
78
+ } catch (error) {
79
+ if (error instanceof ReviewBundlePlanError) {
80
+ return errorResponse(error.code, error.message, error.details);
81
+ }
82
+ return errorResponse(
83
+ "PREVIEW_FAILED",
84
+ error instanceof Error ? error.message : "Failed to generate review bundle preview."
85
+ );
86
+ }
87
+ }
88
+ );
89
+
90
+ // ---- create_review_bundle -----------------------------------------------
91
+ s.tool(
92
+ "create_review_bundle",
93
+ "Generate review bundle artifacts (PDF/markdown) from planned scene scope. Writes files only under output_dir and returns manifest/provenance details.",
94
+ {
95
+ project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
96
+ profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
97
+ output_dir: z.string().describe("Directory path to write bundle artifacts into."),
98
+ part: z.number().int().optional().describe("Optional part filter."),
99
+ chapter: z.number().int().optional().describe("Optional chapter filter."),
100
+ tag: z.string().optional().describe("Optional tag filter (exact match)."),
101
+ scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
102
+ strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
103
+ include_scene_ids: z.boolean().optional().describe("Include scene IDs in headings (default true). Applies to both PDF and markdown."),
104
+ include_metadata_sidebar: z.boolean().optional().describe("Include metadata sidebar in markdown output (default false). Markdown only — no effect on PDF."),
105
+ include_paragraph_anchors: z.boolean().optional().describe("Include paragraph anchors in markdown output (default false). Markdown only — no effect on PDF."),
106
+ recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
107
+ bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in filenames)."),
108
+ source_commit: z.string().optional().describe("Optional explicit source commit for provenance. Defaults to current HEAD when available."),
109
+ format: z.enum(["pdf", "markdown", "both"]).optional().describe("Output format: pdf (default), markdown, or both."),
110
+ },
111
+ async ({
112
+ project_id,
113
+ profile,
114
+ output_dir,
115
+ part,
116
+ chapter,
117
+ tag,
118
+ scene_ids,
119
+ strictness = "warn",
120
+ include_scene_ids = true,
121
+ include_metadata_sidebar = false,
122
+ include_paragraph_anchors = false,
123
+ recipient_name,
124
+ bundle_name,
125
+ source_commit,
126
+ format = "pdf",
127
+ }) => {
128
+ const projectIdCheck = validateProjectId(project_id);
129
+ if (!projectIdCheck.ok) {
130
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
131
+ }
132
+
133
+ try {
134
+ const { resolvedOutputDir, relativeToSyncDir } = resolveOutputDirWithinSync(output_dir);
135
+ const outputDirSegments = relativeToSyncDir
136
+ .split(path.sep)
137
+ .filter(Boolean)
138
+ .map(segment => segment.toLowerCase());
139
+ if (outputDirSegments.includes("scenes")) {
140
+ return errorResponse(
141
+ "INVALID_OUTPUT_DIR",
142
+ "output_dir cannot be inside a scenes directory. Choose a dedicated export folder under WRITING_SYNC_DIR.",
143
+ { output_dir: resolvedOutputDir }
144
+ );
145
+ }
146
+
147
+ const plan = buildReviewBundlePlan(db, {
148
+ project_id,
149
+ profile,
150
+ part,
151
+ chapter,
152
+ tag,
153
+ scene_ids,
154
+ strictness,
155
+ include_scene_ids,
156
+ include_metadata_sidebar,
157
+ include_paragraph_anchors,
158
+ recipient_name,
159
+ bundle_name,
160
+ format,
161
+ });
162
+
163
+ if (!plan.strictness_result.can_proceed) {
164
+ return errorResponse(
165
+ "STRICTNESS_BLOCKED",
166
+ "Bundle generation blocked by strictness policy.",
167
+ { strictness_result: plan.strictness_result, warning_summary: plan.warning_summary }
168
+ );
169
+ }
170
+
171
+ const provenanceCommit = source_commit ?? (GIT_ENABLED ? getHeadCommitHash(SYNC_DIR) : null);
172
+ const artifacts = await createReviewBundleArtifacts(db, {
173
+ plan,
174
+ output_dir: resolvedOutputDir,
175
+ source_commit: provenanceCommit,
176
+ syncDir: SYNC_DIR_ABS,
177
+ });
178
+
179
+ return jsonResponse({
180
+ ok: true,
181
+ bundle_id: artifacts.bundle_id,
182
+ output_paths: artifacts.output_paths,
183
+ summary: {
184
+ scene_count: plan.summary.scene_count,
185
+ profile: plan.profile,
186
+ applied_filters: plan.resolved_scope.filters,
187
+ },
188
+ warnings: plan.warnings,
189
+ warning_summary: plan.warning_summary,
190
+ provenance: {
191
+ source_commit: provenanceCommit,
192
+ generated_at: artifacts.generated_at,
193
+ project_id: plan.resolved_scope.project_id,
194
+ },
195
+ });
196
+ } catch (error) {
197
+ if (error instanceof ReviewBundlePlanError) {
198
+ return errorResponse(error.code, error.message, error.details);
199
+ }
200
+ return errorResponse(
201
+ "CREATE_BUNDLE_FAILED",
202
+ error instanceof Error ? error.message : "Failed to create review bundle artifacts."
203
+ );
204
+ }
205
+ }
206
+ );
207
+ }