@hanna84/mcp-writing 1.16.2 → 1.17.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 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
+ #### [v1.17.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.16.2...v1.17.0)
9
+
10
+ - feat: add beta_reader_personalized review bundle profile [`#77`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/77)
12
+
7
13
  #### [v1.16.2](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v1.16.1...v1.16.2)
9
15
 
16
+ > 25 April 2026
17
+
10
18
  - docs: close out review bundles M1 and add troubleshooting guidance [`#76`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/76)
20
+ - Release 1.16.2 [`557623a`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/557623afb18e4f5f5dbec565eee1cbd481051275)
12
22
 
13
23
  #### [v1.16.1](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v1.16.0...v1.16.1)
package/index.js CHANGED
@@ -1352,7 +1352,7 @@ function createMcpServer() {
1352
1352
  "Dry-run planning tool for review bundles. Resolves scene scope, deterministic ordering, warnings, and planned output filenames without writing files. Note: include_scene_ids/include_metadata_sidebar/include_paragraph_anchors are advisory placeholders in Phase 4A.1 and do not alter planning semantics yet.",
1353
1353
  {
1354
1354
  project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
1355
- profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion or editor_detailed."),
1355
+ profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
1356
1356
  part: z.number().int().optional().describe("Optional part filter."),
1357
1357
  chapter: z.number().int().optional().describe("Optional chapter filter."),
1358
1358
  tag: z.string().optional().describe("Optional tag filter (exact match)."),
@@ -1361,6 +1361,7 @@ function createMcpServer() {
1361
1361
  include_scene_ids: z.boolean().optional().describe("Advisory placeholder for later rendering behavior (default true). Included in preview output options, but does not change planning results in Phase 4A.1."),
1362
1362
  include_metadata_sidebar: z.boolean().optional().describe("Advisory placeholder for later rendering behavior (default false). Included in preview output options, but does not change planning results in Phase 4A.1."),
1363
1363
  include_paragraph_anchors: z.boolean().optional().describe("Advisory placeholder for later rendering behavior (default false). Included in preview output options, but does not change planning results in Phase 4A.1."),
1364
+ recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
1364
1365
  bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in planned outputs)."),
1365
1366
  },
1366
1367
  async ({
@@ -1374,6 +1375,7 @@ function createMcpServer() {
1374
1375
  include_scene_ids = true,
1375
1376
  include_metadata_sidebar = false,
1376
1377
  include_paragraph_anchors = false,
1378
+ recipient_name,
1377
1379
  bundle_name,
1378
1380
  }) => {
1379
1381
  const projectIdCheck = validateProjectId(project_id);
@@ -1393,6 +1395,7 @@ function createMcpServer() {
1393
1395
  include_scene_ids,
1394
1396
  include_metadata_sidebar,
1395
1397
  include_paragraph_anchors,
1398
+ recipient_name,
1396
1399
  bundle_name,
1397
1400
  });
1398
1401
  return jsonResponse(plan);
@@ -1414,7 +1417,7 @@ function createMcpServer() {
1414
1417
  "Generate markdown review bundle artifacts from planned scene scope. Writes files only under output_dir and returns manifest/provenance details.",
1415
1418
  {
1416
1419
  project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
1417
- profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion or editor_detailed."),
1420
+ profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
1418
1421
  output_dir: z.string().describe("Directory path to write bundle artifacts into."),
1419
1422
  part: z.number().int().optional().describe("Optional part filter."),
1420
1423
  chapter: z.number().int().optional().describe("Optional chapter filter."),
@@ -1424,6 +1427,7 @@ function createMcpServer() {
1424
1427
  include_scene_ids: z.boolean().optional().describe("Include scene IDs in markdown headings (default true)."),
1425
1428
  include_metadata_sidebar: z.boolean().optional().describe("Include metadata sidebar in markdown output (default false)."),
1426
1429
  include_paragraph_anchors: z.boolean().optional().describe("Include paragraph anchors in markdown output (default false)."),
1430
+ recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
1427
1431
  bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in filenames)."),
1428
1432
  source_commit: z.string().optional().describe("Optional explicit source commit for provenance. Defaults to current HEAD when available."),
1429
1433
  },
@@ -1439,6 +1443,7 @@ function createMcpServer() {
1439
1443
  include_scene_ids = true,
1440
1444
  include_metadata_sidebar = false,
1441
1445
  include_paragraph_anchors = false,
1446
+ recipient_name,
1442
1447
  bundle_name,
1443
1448
  source_commit,
1444
1449
  }) => {
@@ -1472,6 +1477,7 @@ function createMcpServer() {
1472
1477
  include_scene_ids,
1473
1478
  include_metadata_sidebar,
1474
1479
  include_paragraph_anchors,
1480
+ recipient_name,
1475
1481
  bundle_name,
1476
1482
  });
1477
1483
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.16.2",
3
+ "version": "1.17.0",
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",
@@ -29,6 +29,7 @@
29
29
  "manual:test": "node scripts/manual/test.mjs",
30
30
  "manual:scenarios": "node scripts/manual/test-scenarios.mjs",
31
31
  "manual:merge-beta-test": "node scripts/manual/run_mcp_test.js",
32
+ "manual:review-bundle": "node scripts/manual/run_create_review_bundle.js",
32
33
  "setup:openclaw-env": "sh scripts/setup-openclaw-env.sh",
33
34
  "release": "release-it",
34
35
  "lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/",
package/review-bundles.js CHANGED
@@ -4,7 +4,7 @@ import matter from "gray-matter";
4
4
 
5
5
  const MAX_SORT_VALUE = Number.MAX_SAFE_INTEGER;
6
6
 
7
- export const REVIEW_BUNDLE_PROFILES = ["outline_discussion", "editor_detailed"];
7
+ export const REVIEW_BUNDLE_PROFILES = ["outline_discussion", "editor_detailed", "beta_reader_personalized"];
8
8
  export const REVIEW_BUNDLE_STRICTNESS = ["warn", "fail"];
9
9
 
10
10
  export class ReviewBundlePlanError extends Error {
@@ -63,6 +63,62 @@ function escapeMarkdown(text) {
63
63
  .replace(/([*_`\[\]#])/g, "\\$1");
64
64
  }
65
65
 
66
+ function normalizeRecipientDisplayName(recipientName) {
67
+ const normalized = String(recipientName ?? "")
68
+ .replace(/[\x00-\x1f\x7f]+/g, " ")
69
+ .replace(/\s+/g, " ")
70
+ .trim()
71
+ .slice(0, 100);
72
+
73
+ return normalized || "Beta Reader";
74
+ }
75
+
76
+ function renderBetaNoticeMarkdown({ projectId, recipientName }) {
77
+ const displayName = normalizeRecipientDisplayName(recipientName);
78
+ return [
79
+ "# Non-Distribution Notice",
80
+ "",
81
+ `This review packet is prepared for ${escapeMarkdown(displayName)} for private beta-reading purposes only.`,
82
+ "",
83
+ "Please do not distribute, repost, or share this material without explicit author permission.",
84
+ "",
85
+ "This notice is informational only and is not legal advice.",
86
+ "",
87
+ `Project: ${escapeMarkdown(projectId)}`,
88
+ ].join("\n") + "\n";
89
+ }
90
+
91
+ function renderBetaFeedbackFormMarkdown({ projectId, recipientName, generatedAt }) {
92
+ const displayName = normalizeRecipientDisplayName(recipientName);
93
+ const feedbackDate = String(generatedAt ?? new Date().toISOString()).slice(0, 10);
94
+ return [
95
+ "# Beta Reader Feedback Form",
96
+ "",
97
+ `- Project: ${escapeMarkdown(projectId)}`,
98
+ `- Reader: ${escapeMarkdown(displayName)}`,
99
+ `- Date: ${feedbackDate}`,
100
+ "",
101
+ "## Big-Picture Questions",
102
+ "",
103
+ "1. Which sections felt most compelling, and why?",
104
+ "2. Where did pacing feel slow, rushed, or unclear?",
105
+ "3. Were any character motivations confusing or unconvincing?",
106
+ "",
107
+ "## Scene-Level Notes",
108
+ "",
109
+ "Use scene IDs when possible.",
110
+ "",
111
+ "- Scene ID:",
112
+ "- Comment:",
113
+ "- Severity (nit / moderate / major):",
114
+ "",
115
+ "## Final Thoughts",
116
+ "",
117
+ "- What should be prioritized in the next revision?",
118
+ "- Any continuity concerns to flag?",
119
+ ].join("\n") + "\n";
120
+ }
121
+
66
122
  function resolveOutputFilePath(outputDir, fileName) {
67
123
  const normalizedOutputDir = path.resolve(outputDir);
68
124
  const target = path.resolve(normalizedOutputDir, fileName);
@@ -125,6 +181,7 @@ export function buildReviewBundlePlan(dbHandle, {
125
181
  include_metadata_sidebar = false,
126
182
  include_paragraph_anchors = false,
127
183
  bundle_name,
184
+ recipient_name,
128
185
  } = {}) {
129
186
  if (!project_id) {
130
187
  throw new ReviewBundlePlanError("INVALID_PROJECT_ID", "project_id is required.");
@@ -264,6 +321,9 @@ export function buildReviewBundlePlan(dbHandle, {
264
321
  const count = Number(row.word_count);
265
322
  return sum + (Number.isFinite(count) ? count : 0);
266
323
  }, 0);
324
+ const resolvedRecipientName = profile === "beta_reader_personalized"
325
+ ? normalizeRecipientDisplayName(recipient_name)
326
+ : undefined;
267
327
 
268
328
  const safeBundleName = slugifyBundleName(bundle_name || `${project_id}-${profile}`);
269
329
  const appliedFilters = {
@@ -283,6 +343,7 @@ export function buildReviewBundlePlan(dbHandle, {
283
343
  include_scene_ids: Boolean(include_scene_ids),
284
344
  include_metadata_sidebar: Boolean(include_metadata_sidebar),
285
345
  include_paragraph_anchors: Boolean(include_paragraph_anchors),
346
+ ...(resolvedRecipientName ? { recipient_name: resolvedRecipientName } : {}),
286
347
  },
287
348
  },
288
349
  ordering: rows.map(row => ({
@@ -308,6 +369,12 @@ export function buildReviewBundlePlan(dbHandle, {
308
369
  },
309
370
  planned_outputs: [
310
371
  `${safeBundleName}.md`,
372
+ ...(profile === "beta_reader_personalized"
373
+ ? [
374
+ `${safeBundleName}.notice.md`,
375
+ `${safeBundleName}.feedback-form.md`,
376
+ ]
377
+ : []),
311
378
  `${safeBundleName}.manifest.json`,
312
379
  ],
313
380
  };
@@ -567,19 +634,35 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
567
634
  const sceneIds = plan.ordering.map(row => row.scene_id);
568
635
  const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
569
636
  const sections = [];
637
+ const recipientName = plan.resolved_scope?.options?.recipient_name;
638
+ const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
570
639
 
571
640
  const headerLines = [
572
641
  `# Review Bundle: ${escapeMarkdown(plan.resolved_scope.project_id)}`,
573
642
  "",
574
643
  `- Profile: ${profile}`,
644
+ ...(profile === "beta_reader_personalized"
645
+ ? [`- Recipient: ${escapeMarkdown(recipientDisplayName)}`]
646
+ : []),
575
647
  `- Generated at: ${generatedAt ?? new Date().toISOString()}`,
576
648
  `- Scene count: ${plan.summary.scene_count}`,
577
649
  ];
578
650
  sections.push(headerLines.join("\n"));
579
651
 
652
+ if (profile === "beta_reader_personalized") {
653
+ sections.push(
654
+ [
655
+ "## Usage Notice",
656
+ "",
657
+ "This beta-reader draft is intended for private review and feedback.",
658
+ "Please do not redistribute without explicit author permission.",
659
+ ].join("\n")
660
+ );
661
+ }
662
+
580
663
  for (const scene of rows) {
581
664
  let prose = "";
582
- if (profile === "editor_detailed") {
665
+ if (profile === "editor_detailed" || profile === "beta_reader_personalized") {
583
666
  const resolved = readProse(scene.file_path, { syncDir });
584
667
  if (resolved === null) {
585
668
  throw new ReviewBundlePlanError(
@@ -636,7 +719,14 @@ export function createReviewBundleArtifacts(dbHandle, {
636
719
  );
637
720
  }
638
721
 
639
- const markdownFileName = plan.planned_outputs.find(name => name.endsWith(".md"));
722
+ const noticeFileName = plan.planned_outputs.find(name => name.endsWith(".notice.md")) ?? null;
723
+ const feedbackFileName = plan.planned_outputs.find(name => name.endsWith(".feedback-form.md")) ?? null;
724
+ const markdownFileName = plan.planned_outputs.find(
725
+ name =>
726
+ name.endsWith(".md") &&
727
+ !name.endsWith(".notice.md") &&
728
+ !name.endsWith(".feedback-form.md")
729
+ );
640
730
  const manifestFileName = plan.planned_outputs.find(name => name.endsWith(".manifest.json"));
641
731
  if (!markdownFileName || !manifestFileName) {
642
732
  throw new ReviewBundlePlanError(
@@ -647,9 +737,18 @@ export function createReviewBundleArtifacts(dbHandle, {
647
737
 
648
738
  const markdownPath = resolveOutputFilePath(normalizedOutputDir, markdownFileName);
649
739
  const manifestPath = resolveOutputFilePath(normalizedOutputDir, manifestFileName);
740
+ const noticePath = noticeFileName ? resolveOutputFilePath(normalizedOutputDir, noticeFileName) : null;
741
+ const feedbackPath = feedbackFileName ? resolveOutputFilePath(normalizedOutputDir, feedbackFileName) : null;
650
742
 
651
743
  const generatedAt = new Date().toISOString();
652
744
  const markdown = renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir });
745
+ const recipientName = plan.resolved_scope?.options?.recipient_name;
746
+ const betaNotice = plan.profile === "beta_reader_personalized"
747
+ ? renderBetaNoticeMarkdown({ projectId: plan.resolved_scope.project_id, recipientName })
748
+ : null;
749
+ const betaFeedbackForm = plan.profile === "beta_reader_personalized"
750
+ ? renderBetaFeedbackFormMarkdown({ projectId: plan.resolved_scope.project_id, recipientName, generatedAt })
751
+ : null;
653
752
  const manifest = {
654
753
  bundle_id: path.basename(markdownFileName, ".md"),
655
754
  profile: plan.profile,
@@ -665,7 +764,7 @@ export function createReviewBundleArtifacts(dbHandle, {
665
764
  scene_ids: plan.ordering.map(row => row.scene_id),
666
765
  };
667
766
 
668
- for (const outputPath of [markdownPath, manifestPath]) {
767
+ for (const outputPath of [markdownPath, manifestPath, noticePath, feedbackPath].filter(Boolean)) {
669
768
  try {
670
769
  const stat = fs.lstatSync(outputPath);
671
770
  if (stat.isSymbolicLink()) {
@@ -692,12 +791,20 @@ export function createReviewBundleArtifacts(dbHandle, {
692
791
 
693
792
  fs.writeFileSync(markdownPath, markdown, "utf8");
694
793
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
794
+ if (noticePath && betaNotice != null) {
795
+ fs.writeFileSync(noticePath, betaNotice, "utf8");
796
+ }
797
+ if (feedbackPath && betaFeedbackForm != null) {
798
+ fs.writeFileSync(feedbackPath, betaFeedbackForm, "utf8");
799
+ }
695
800
 
696
801
  return {
697
802
  bundle_id: manifest.bundle_id,
698
803
  output_paths: {
699
804
  bundle_markdown: markdownPath,
700
805
  manifest_json: manifestPath,
806
+ ...(noticePath ? { notice_md: noticePath } : {}),
807
+ ...(feedbackPath ? { feedback_form_md: feedbackPath } : {}),
701
808
  },
702
809
  generated_at: generatedAt,
703
810
  };
@@ -2,11 +2,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
3
  import path from "path";
4
4
  import process from "process";
5
+ import fs from "node:fs";
5
6
 
6
7
  async function main() {
7
8
  const [projectId, profile, outputDir, ...flags] = process.argv.slice(2);
8
9
  if (!projectId || !profile || !outputDir) {
9
- console.error("Usage: WRITING_SYNC_DIR=/path/to/sync node scripts/manual/run_create_review_bundle.js <project_id> <profile> <output_dir> [--anchors] [--bundle-name <name>]");
10
+ console.error("Usage: WRITING_SYNC_DIR=/path/to/sync node scripts/manual/run_create_review_bundle.js <project_id> <profile> <output_dir> [--anchors] [--recipient <name>] [--bundle-name <name>] [--skip-preview] [--show-files]");
10
11
  process.exit(1);
11
12
  }
12
13
 
@@ -19,8 +20,28 @@ async function main() {
19
20
  }
20
21
 
21
22
  const includeParagraphAnchors = flags.includes("--anchors");
23
+ const skipPreview = flags.includes("--skip-preview");
24
+ const showFiles = flags.includes("--show-files");
22
25
  const bundleNameIdx = flags.indexOf("--bundle-name");
23
26
  const bundleName = bundleNameIdx !== -1 ? flags[bundleNameIdx + 1] : undefined;
27
+ const recipientIdx = flags.indexOf("--recipient");
28
+ const recipientName = recipientIdx !== -1 ? flags[recipientIdx + 1] : undefined;
29
+
30
+ const baseArguments = {
31
+ project_id: projectId,
32
+ profile,
33
+ ...(includeParagraphAnchors ? { include_paragraph_anchors: true } : {}),
34
+ ...(bundleName ? { bundle_name: bundleName } : {}),
35
+ ...(recipientName ? { recipient_name: recipientName } : {}),
36
+ };
37
+
38
+ function printArtifactExcerpt(label, filePath) {
39
+ if (!filePath || !fs.existsSync(filePath)) return;
40
+ const content = fs.readFileSync(filePath, "utf8");
41
+ const excerpt = content.split("\n").slice(0, 20).join("\n");
42
+ console.log(`\n--- ${label}: ${filePath} ---`);
43
+ console.log(excerpt);
44
+ }
24
45
 
25
46
  const transport = new StdioClientTransport({
26
47
  command: process.execPath,
@@ -41,18 +62,38 @@ async function main() {
41
62
 
42
63
  try {
43
64
  await client.connect(transport);
65
+
66
+ if (!skipPreview) {
67
+ const previewResult = await client.callTool({
68
+ name: "preview_review_bundle",
69
+ arguments: baseArguments,
70
+ });
71
+ const previewText = previewResult.content?.[0]?.text ?? "";
72
+ console.log("\n=== preview_review_bundle ===");
73
+ console.log(previewText);
74
+ }
75
+
44
76
  const result = await client.callTool({
45
77
  name: "create_review_bundle",
46
78
  arguments: {
47
- project_id: projectId,
48
- profile: profile,
79
+ ...baseArguments,
49
80
  output_dir: outputDir,
50
- ...(includeParagraphAnchors ? { include_paragraph_anchors: true } : {}),
51
- ...(bundleName ? { bundle_name: bundleName } : {})
52
81
  }
53
82
  });
54
83
 
55
- console.log(JSON.stringify(result, null, 2));
84
+ const resultText = result.content?.[0]?.text ?? "";
85
+ console.log("\n=== create_review_bundle ===");
86
+ console.log(resultText);
87
+
88
+ if (showFiles) {
89
+ const parsed = JSON.parse(resultText);
90
+ if (parsed.ok && parsed.output_paths) {
91
+ printArtifactExcerpt("Bundle Markdown", parsed.output_paths.bundle_markdown);
92
+ printArtifactExcerpt("Manifest JSON", parsed.output_paths.manifest_json);
93
+ printArtifactExcerpt("Notice Markdown", parsed.output_paths.notice_md);
94
+ printArtifactExcerpt("Feedback Form Markdown", parsed.output_paths.feedback_form_md);
95
+ }
96
+ }
56
97
  } catch (e) {
57
98
  console.error(e);
58
99
  process.exitCode = 1;