@hanna84/mcp-writing 2.9.5 → 2.9.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.9.5",
3
+ "version": "2.9.7",
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
+ }