@superdoc/mcp 1.9.0-next.12 → 1.9.0-next.13

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.
Files changed (2) hide show
  1. package/dist/index.js +243 -58
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -31620,7 +31620,7 @@ var init_host = __esm(() => {
31620
31620
  });
31621
31621
 
31622
31622
  // ../../packages/sdk/langs/node/src/runtime/sdk-version.generated.ts
31623
- var SDK_VERSION = "2.22.0-next.12";
31623
+ var SDK_VERSION = "2.22.0-next.13";
31624
31624
 
31625
31625
  // ../../packages/sdk/langs/node/src/runtime/embedded-platform.ts
31626
31626
  import { accessSync, chmodSync, constants, existsSync, statSync } from "node:fs";
@@ -87423,10 +87423,10 @@ var init_action_descriptors = __esm(() => {
87423
87423
  operations: ["create.heading"]
87424
87424
  },
87425
87425
  replace_text: {
87426
- hint: "edits[{find,replace}], optional selector to scope replacements to one inspected block, caseSensitive?, changeMode? — for EDITING existing wording only; the replaced span is rewritten as PLAIN text (intra-span bold/italic is dropped), so keep find/replace spans MINIMAL (the words that change, not the whole paragraph) and NEVER use it to append a new sentence/clause/paragraph (use insert_paragraphs)",
87426
+ hint: "edits[{find,replace,occurrence?}], optional selector to scope replacements to one inspected block, caseSensitive?, expectedRevision?, changeMode? — for EDITING existing wording only; keep find/replace spans MINIMAL (the words that change, not the whole paragraph). When the find appears more than once in the selected block and only one should change, set that edit's occurrence (1-based match number within the block; requires selector; this is not selector.occurrence, which picks a block). NEVER use it to append a new sentence/clause/paragraph (use insert_paragraphs)",
87427
87427
  group: "Text/structure actions",
87428
87428
  groupOrder: 2,
87429
- args: ["edits", "selector", "caseSensitive", "changeMode"],
87429
+ args: ["edits", "selector", "caseSensitive", "expectedRevision", "changeMode"],
87430
87430
  operations: ["replace"]
87431
87431
  },
87432
87432
  delete_text: {
@@ -93107,12 +93107,10 @@ function normalizeTitleLikeRewriteText(targetText, rewrittenText) {
93107
93107
  const displayTitle = toDisplayTitleCase(targetText);
93108
93108
  if (rewrittenText.includes(displayTitle))
93109
93109
  return rewrittenText;
93110
- const lowerTarget = targetText.toLocaleLowerCase();
93111
- const lowerRewrite = rewrittenText.toLocaleLowerCase();
93112
- const index = lowerRewrite.indexOf(lowerTarget);
93113
- if (index < 0)
93110
+ const match = findRanges(rewrittenText, targetText, false)[0];
93111
+ if (!match)
93114
93112
  return rewrittenText;
93115
- return `${rewrittenText.slice(0, index)}${displayTitle}${rewrittenText.slice(index + targetText.length)}`;
93113
+ return `${rewrittenText.slice(0, match.start)}${displayTitle}${rewrittenText.slice(match.end)}`;
93116
93114
  }
93117
93115
  async function executeCreateParagraph(doc2, text, placement, changeMode) {
93118
93116
  const fn = maybeMethod2(doc2, ["create", "paragraph"]);
@@ -93417,6 +93415,7 @@ async function runReplaceTextUnchecked(doc2, args) {
93417
93415
  const preIdentity = requiresBlockSnapshot ? null : await readDocumentIdentity(doc2);
93418
93416
  const pre = requiresBlockSnapshot ? await buildMutationSnapshot(doc2, { includeDomains: selectorDomains ?? ["blocks"] }) : snapshotFromIdentity(preIdentity);
93419
93417
  try {
93418
+ assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
93420
93419
  assertReplacementTextPayloads(args.edits);
93421
93420
  if (args.edits.length === 0) {
93422
93421
  return failedReceipt("replace_text", new Error("edits must be non-empty"), pre);
@@ -93425,7 +93424,7 @@ async function runReplaceTextUnchecked(doc2, args) {
93425
93424
  const selectedTargets = [];
93426
93425
  let skippedEdits = [];
93427
93426
  let steps;
93428
- let applyExpectedRevision;
93427
+ let applyExpectedRevision = args.expectedRevision != null ? pre.revision : undefined;
93429
93428
  if (args.selector) {
93430
93429
  const target = selectorToBlockTarget(args.selector, pre);
93431
93430
  if (!target) {
@@ -95411,18 +95410,25 @@ function findRanges(haystack, needle, caseSensitive) {
95411
95410
  const ranges = [];
95412
95411
  if (needle.length === 0)
95413
95412
  return ranges;
95414
- const source = caseSensitive ? haystack : haystack.toLocaleLowerCase();
95415
- const target = caseSensitive ? needle : needle.toLocaleLowerCase();
95416
- let offset = 0;
95417
- while (offset < source.length) {
95418
- const idx = source.indexOf(target, offset);
95419
- if (idx < 0)
95420
- break;
95421
- ranges.push({ start: idx, end: idx + target.length });
95422
- offset = idx + target.length;
95413
+ if (caseSensitive) {
95414
+ let offset = 0;
95415
+ while (offset < haystack.length) {
95416
+ const idx = haystack.indexOf(needle, offset);
95417
+ if (idx < 0)
95418
+ break;
95419
+ ranges.push({ start: idx, end: idx + needle.length });
95420
+ offset = idx + needle.length;
95421
+ }
95422
+ return ranges;
95423
+ }
95424
+ for (const match of haystack.matchAll(caseInsensitivePattern(needle))) {
95425
+ ranges.push({ start: match.index, end: match.index + match[0].length });
95423
95426
  }
95424
95427
  return ranges;
95425
95428
  }
95429
+ function caseInsensitivePattern(needle) {
95430
+ return new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "giu");
95431
+ }
95426
95432
  async function runApplyLetterSpacing(doc2, args) {
95427
95433
  const pre = await buildMutationSnapshot(doc2, { includeDomains: ["blocks"] });
95428
95434
  try {
@@ -96168,29 +96174,59 @@ function trackedChangeRefIds(result) {
96168
96174
  return entityId ? [entityId] : [];
96169
96175
  }));
96170
96176
  }
96171
- function parseScopedReplaceArgs(args, changeMode) {
96172
- if (args.action !== "replace_text")
96173
- return null;
96177
+ function parseScopedReplaceArgs(args, edits, changeMode, expectedRevision) {
96174
96178
  if (!isRecord2(args.selector))
96175
96179
  return null;
96176
- if (!Array.isArray(args.edits) || args.edits.length === 0)
96177
- return null;
96178
- const edits = [];
96179
- for (const entry of args.edits) {
96180
- if (!isRecord2(entry) || typeof entry.find !== "string" || entry.find.length === 0)
96181
- return null;
96182
- edits.push({ find: entry.find, replace: typeof entry.replace === "string" ? entry.replace : "" });
96183
- }
96184
96180
  return {
96185
96181
  edits,
96186
96182
  selector: args.selector,
96187
96183
  caseSensitive: args.caseSensitive === true,
96184
+ expectedRevision,
96188
96185
  changeMode
96189
96186
  };
96190
96187
  }
96188
+ function assertExpectedReplacementRevision(expected, actual) {
96189
+ if (expected != null && expected !== actual) {
96190
+ throw new SuperDocCliError("replace_text: expectedRevision is stale. Inspect this live document again before editing.", {
96191
+ code: "REVISION_CONFLICT"
96192
+ });
96193
+ }
96194
+ }
96195
+ async function fieldResultRanges(doc2, nodeId, blockText) {
96196
+ const fn = maybeMethod2(doc2, ["fields", "list"]);
96197
+ if (!fn)
96198
+ return null;
96199
+ const results = [];
96200
+ const limit = 500;
96201
+ for (let offset = 0;offset < 1e5; offset += limit) {
96202
+ const page = await fn({ limit, offset });
96203
+ const items = isRecord2(page) && Array.isArray(page.items) ? page.items.filter(isRecord2) : [];
96204
+ for (const item of items) {
96205
+ const address = isRecord2(item.address) ? item.address : null;
96206
+ const inBody = typeof address?.storyId !== "string" || address.storyId.startsWith("main:");
96207
+ if (address?.blockId !== nodeId || !inBody)
96208
+ continue;
96209
+ if (typeof item.resolvedText !== "string" || item.resolvedText.length === 0) {
96210
+ return [{ start: 0, end: blockText.length }];
96211
+ }
96212
+ results.push(item.resolvedText);
96213
+ }
96214
+ const total = isRecord2(page) && typeof page.total === "number" ? page.total : null;
96215
+ if (items.length < limit || total != null && offset + limit >= total)
96216
+ break;
96217
+ }
96218
+ const ranges = [];
96219
+ for (const result of results) {
96220
+ for (let at = blockText.indexOf(result);at >= 0; at = blockText.indexOf(result, at + 1)) {
96221
+ ranges.push({ start: at, end: at + result.length });
96222
+ }
96223
+ }
96224
+ return ranges;
96225
+ }
96191
96226
  async function runScopedReplaceUnchecked(doc2, args) {
96192
96227
  assertReplacementTextPayloads(args.edits);
96193
96228
  const pre = await buildMutationSnapshot(doc2, { includeDomains: ["blocks", "tables"] });
96229
+ assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
96194
96230
  const matched = resolveSnapshotSelector(pre, args.selector);
96195
96231
  if (matched.length !== 1) {
96196
96232
  const failure2 = describeSelectorFailure(args.selector, matched, pre, args.edits.map((e) => e.find));
@@ -96210,13 +96246,22 @@ async function runScopedReplaceUnchecked(doc2, args) {
96210
96246
  };
96211
96247
  }
96212
96248
  const caseSensitive = args.caseSensitive === true;
96249
+ const hasExplicitOccurrence = args.edits.some((edit) => edit.occurrence != null);
96213
96250
  const perEdit = [];
96214
96251
  const allSpans = [];
96215
96252
  for (const edit of args.edits) {
96216
- const spans = findRanges(block.text, edit.find, caseSensitive);
96217
- perEdit.push({ find: edit.find, occurrences: spans.length });
96253
+ const matches = findRanges(block.text, edit.find, caseSensitive);
96254
+ const spans = edit.occurrence == null ? matches : matches.slice(edit.occurrence - 1, edit.occurrence);
96255
+ if (hasExplicitOccurrence && spans.length === 0) {
96256
+ return failedReceipt("replace_text", new SuperDocCliError(edit.occurrence == null ? `replace_text: ${JSON.stringify(edit.find)} not found in the selected block. Inspect and choose a valid target.` : `replace_text: ${JSON.stringify(edit.find)} occurrence ${edit.occurrence} not found in the selected block (${matches.length} matches). Inspect and choose a valid target.`, { code: "MATCH_NOT_FOUND" }), pre);
96257
+ }
96258
+ perEdit.push({
96259
+ find: edit.find,
96260
+ occurrences: spans.length,
96261
+ ...edit.occurrence != null ? { occurrence: edit.occurrence } : {}
96262
+ });
96218
96263
  for (const span of spans)
96219
- allSpans.push({ ...span, replace: edit.replace ?? "", find: edit.find });
96264
+ allSpans.push({ ...span, replace: edit.replace, find: edit.find });
96220
96265
  }
96221
96266
  const appliedEdits = perEdit.filter((e) => e.occurrences > 0);
96222
96267
  const skippedEdits = perEdit.filter((e) => e.occurrences === 0);
@@ -96241,6 +96286,9 @@ async function runScopedReplaceUnchecked(doc2, args) {
96241
96286
  let lastEnd = -1;
96242
96287
  for (const span of allSpans) {
96243
96288
  if (span.start < lastEnd) {
96289
+ if (hasExplicitOccurrence) {
96290
+ return failedReceipt("replace_text", new SuperDocCliError("replace_text: explicit occurrence targets overlap. Choose non-overlapping edits in the pre-edit block.", { code: "INVALID_ARGUMENT" }), pre);
96291
+ }
96244
96292
  droppedOverlaps.push(span);
96245
96293
  continue;
96246
96294
  }
@@ -96249,24 +96297,45 @@ async function runScopedReplaceUnchecked(doc2, args) {
96249
96297
  }
96250
96298
  const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
96251
96299
  const tracked = (args.changeMode ?? "direct") === "tracked";
96252
- let result = null;
96253
96300
  const uniqueFinds = new Set(planned.map((s) => caseSensitive ? s.find : s.find.toLowerCase()));
96254
- if (tracked && uniqueFinds.size === planned.length) {
96255
- const spanSteps = planned.map((span, index) => ({
96301
+ const deletions = tracked ? [] : planned.filter((span) => span.replace.length === 0);
96302
+ const fieldRanges = deletions.length > 0 ? await fieldResultRanges(doc2, nodeId, block.text) : [];
96303
+ const fieldDeletionRisk = fieldRanges == null || deletions.some((span) => fieldRanges.some((range) => span.start < range.end && span.end > range.start));
96304
+ const exactTargets = !fieldDeletionRisk && (hasExplicitOccurrence || tracked && uniqueFinds.size === planned.length);
96305
+ let result = null;
96306
+ let wroteExactTargets = false;
96307
+ if (exactTargets) {
96308
+ const targetedSpans = [...planned].reverse().filter((span) => block.text.slice(span.start, span.end) !== span.replace);
96309
+ const steps = targetedSpans.map((span, index) => ({
96256
96310
  id: `scoped-replace-${index + 1}`,
96257
96311
  op: "text.rewrite",
96258
96312
  where: {
96259
- by: "select",
96260
- select: { type: "text", pattern: span.find, mode: "contains", caseSensitive },
96261
- require: "first",
96262
- within: { kind: "block", nodeType: block.nodeType, nodeId }
96313
+ by: "target",
96314
+ target: {
96315
+ kind: "selection",
96316
+ start: { kind: "text", blockId: nodeId, offset: span.start },
96317
+ end: { kind: "text", blockId: nodeId, offset: span.end }
96318
+ }
96263
96319
  },
96264
96320
  args: { replacement: { text: span.replace }, style: preserveRewriteStyle() }
96265
96321
  }));
96266
- try {
96267
- result = await executeMutations(doc2, spanSteps, args.changeMode, pre.revision);
96268
- } catch {
96269
- result = null;
96322
+ if (steps.length === 0) {
96323
+ result = { noop: true };
96324
+ } else {
96325
+ const preview = await previewMutations(doc2, steps, args.changeMode, pre.revision);
96326
+ const previewSteps = Array.isArray(preview.steps) ? preview.steps.filter(isRecord2) : [];
96327
+ const aligned = preview.valid === true && steps.every((step, index) => {
96328
+ const resolutions = previewSteps.find((candidate) => candidate.stepId === step.id)?.resolutions;
96329
+ const resolved = Array.isArray(resolutions) && isRecord2(resolutions[0]) ? resolutions[0].text : undefined;
96330
+ const span = targetedSpans[index];
96331
+ return resolved === block.text.slice(span.start, span.end);
96332
+ });
96333
+ if (aligned) {
96334
+ result = await executeMutations(doc2, steps, args.changeMode, pre.revision);
96335
+ wroteExactTargets = true;
96336
+ } else if (hasExplicitOccurrence) {
96337
+ return failedReceipt("replace_text", new SuperDocCliError("replace_text: the chosen occurrence does not resolve to the same text in the document. Inspect again before editing.", { code: "TARGET_NOT_FOUND" }), pre);
96338
+ }
96270
96339
  }
96271
96340
  }
96272
96341
  if (result == null) {
@@ -96280,9 +96349,20 @@ async function runScopedReplaceUnchecked(doc2, args) {
96280
96349
  ];
96281
96350
  result = await executeMutations(doc2, steps, args.changeMode, pre.revision);
96282
96351
  }
96283
- const post = await buildMutationSnapshot(doc2, { includeDomains: ["blocks", "tables"] });
96284
- const postBlock = findTextTarget(post, nodeId);
96285
- const passed = postBlock != null && (tracked ? planned.every((span) => span.replace.length === 0 || postBlock.text.includes(span.replace)) : postBlock.text === expected);
96352
+ let post = await buildMutationSnapshot(doc2, { includeDomains: ["blocks", "tables"] });
96353
+ let postBlock = findTextTarget(post, nodeId);
96354
+ let rolledBack = false;
96355
+ const appliedRevision = isRecord2(result) && isRecord2(result.revision) ? result.revision.after : undefined;
96356
+ if (wroteExactTargets && postBlock != null && postBlock.text !== expected && postBlock.text !== block.text && typeof appliedRevision === "string" && appliedRevision === post.revision) {
96357
+ const undoFn = maybeMethod2(doc2, ["history", "undo"]);
96358
+ if (undoFn) {
96359
+ await undoFn({});
96360
+ post = await buildMutationSnapshot(doc2, { includeDomains: ["blocks", "tables"] });
96361
+ postBlock = findTextTarget(post, nodeId);
96362
+ rolledBack = postBlock?.text === block.text;
96363
+ }
96364
+ }
96365
+ const passed = postBlock != null && !rolledBack && (tracked && !hasExplicitOccurrence && !wroteExactTargets ? planned.every((span) => span.replace.length === 0 || postBlock.text.includes(span.replace)) : postBlock.text === expected);
96286
96366
  const status = !passed ? "failed" : skippedEdits.length > 0 ? "partial" : "ok";
96287
96367
  return {
96288
96368
  status,
@@ -96291,6 +96371,14 @@ async function runScopedReplaceUnchecked(doc2, args) {
96291
96371
  nextStep: `${skippedEdits.length} of ${perEdit.length} edits were NOT applied — the find text is not in the selected block. ` + `Do not stop: call replace_text again for the remaining edits with a different selector (or no selector for a global replace).`,
96292
96372
  recovery: { kind: "retry" }
96293
96373
  } : {},
96374
+ ...rolledBack ? {
96375
+ errors: [
96376
+ {
96377
+ code: "ACTION_FAILED",
96378
+ message: "replace_text: the engine changed more than the requested text, so the edit was undone. Inspect the block and retry with a larger find or rewrite the whole block."
96379
+ }
96380
+ ]
96381
+ } : {},
96294
96382
  preSnapshot: { revision: pre.revision },
96295
96383
  postSnapshot: { revision: post.revision },
96296
96384
  selectedBlock: { nodeId, nodeType: block.nodeType },
@@ -97622,8 +97710,17 @@ async function dispatchSuperdocPerformAction(doc2, args) {
97622
97710
  code: "INVALID_ARGUMENT"
97623
97711
  });
97624
97712
  }
97713
+ const expectedRevision = args.expectedRevision;
97714
+ if (expectedRevision !== undefined && (typeof expectedRevision !== "string" || expectedRevision.length === 0)) {
97715
+ throw new SuperDocCliError("replace_text: expectedRevision must be a non-empty string from this live document handle", { code: "INVALID_ARGUMENT" });
97716
+ }
97717
+ if (edits.some((edit) => edit.occurrence != null) && !isRecord2(args.selector)) {
97718
+ throw new SuperDocCliError("replace_text: explicit occurrence requires a selector resolving to one block", {
97719
+ code: "INVALID_ARGUMENT"
97720
+ });
97721
+ }
97625
97722
  const changeMode = parseReplaceTextChangeMode(args.changeMode);
97626
- const scoped = parseScopedReplaceArgs(args, changeMode);
97723
+ const scoped = parseScopedReplaceArgs(args, edits, changeMode, expectedRevision);
97627
97724
  if (scoped) {
97628
97725
  return runScopedReplace(doc2, scoped);
97629
97726
  }
@@ -97636,6 +97733,7 @@ async function dispatchSuperdocPerformAction(doc2, args) {
97636
97733
  edits,
97637
97734
  selector,
97638
97735
  caseSensitive: args.caseSensitive === true,
97736
+ expectedRevision,
97639
97737
  changeMode
97640
97738
  });
97641
97739
  }
@@ -98281,7 +98379,11 @@ function parseEdits2(value) {
98281
98379
  const replace = asString2(entry.replace) ?? "";
98282
98380
  if (!find)
98283
98381
  return;
98284
- result.push({ find, replace });
98382
+ const occurrence = entry.occurrence;
98383
+ if (occurrence !== undefined && (typeof occurrence !== "number" || !Number.isSafeInteger(occurrence) || occurrence < 1)) {
98384
+ throw new SuperDocCliError("replace_text: occurrence must be a positive safe integer (one-based within the selected block)", { code: "INVALID_ARGUMENT" });
98385
+ }
98386
+ result.push({ find, replace, ...occurrence !== undefined ? { occurrence } : {} });
98285
98387
  }
98286
98388
  return result;
98287
98389
  }
@@ -98743,10 +98845,24 @@ var init_catalog = __esm(() => {
98743
98845
  type: "array",
98744
98846
  items: {
98745
98847
  type: "object",
98746
- properties: { find: { type: "string" }, replace: { type: "string" } },
98848
+ properties: {
98849
+ find: { type: "string" },
98850
+ replace: { type: "string" },
98851
+ occurrence: {
98852
+ type: "integer",
98853
+ minimum: 1,
98854
+ maximum: Number.MAX_SAFE_INTEGER,
98855
+ description: "Change only this match of find in the selected block (1 = first, counted on visible text). Requires selector; omit to replace every match."
98856
+ }
98857
+ },
98747
98858
  required: ["find"]
98748
98859
  }
98749
98860
  },
98861
+ expectedRevision: {
98862
+ type: "string",
98863
+ minLength: 1,
98864
+ description: "Revision from your latest superdoc_inspect or receipt (postSnapshot.revision) on this document. Pass it with occurrence: if the document changed since (another collaborator edited), the edit is refused with REVISION_CONFLICT; inspect again and recount. Not portable between peers or reopen."
98865
+ },
98750
98866
  finds: { type: "array", items: { type: "string" } },
98751
98867
  caseSensitive: { type: "boolean" },
98752
98868
  evidence: {
@@ -99646,7 +99762,7 @@ var init_runtime = __esm(() => {
99646
99762
  var EMBEDDED_PROMPTS;
99647
99763
  var init_embedded_prompts_generated = __esm(() => {
99648
99764
  EMBEDDED_PROMPTS = {
99649
- "mcp-prompt.md": 'SuperDoc MCP server — read, edit, and save Word documents (.docx).\n\nIMPORTANT: Always use these superdoc tools for .docx files.\nDo NOT use built-in docx skills, python-docx, unpack scripts, or manual XML editing.\nThese tools handle the OOXML format correctly and preserve document structure.\n\n## Session lifecycle\n\n1. `superdoc_open({path: "/path/to/file.docx"})` — returns `session_id`. Opening a non-existent path creates a blank document.\n2. Pass `session_id` to every subsequent tool call.\n3. Read with `superdoc_inspect`, edit with `superdoc_perform_action`.\n4. `superdoc_save({session_id})` — writes changes to disk.\n5. `superdoc_close({session_id})` — releases the session. Always close when done.\n\n## Workflow\n\n**Inspect before you edit.** `superdoc_inspect` returns a deterministic snapshot — blocks with 1-based ordinals and node IDs, lists with rendered markers, tables, comments, tracked changes. Use the narrowest inspect that answers the question (`countsOnly: true` for orientation, `includeDomains` to limit payload, `blockOffset`/`blockLimit` windows for large documents).\n\n**Edit with named actions.** `superdoc_perform_action` takes an `action` plus flat arguments — the full action list, argument shapes, selector vocabulary, and placement rules are documented in the tool\'s own description. Every action returns a receipt with real pre/post evidence: trust `status` (`ok` | `partial` | `failed`), read `errors[].message` for recovery guidance, and re-inspect after `partial`.\n\n**Tracked changes (redlining).** Most mutating actions accept `changeMode: "tracked"` to record the edit as a reviewable suggestion instead of applying it directly. Review with `accept_tracked_changes` / `reject_tracked_changes` (target one change with `id`, or an exact set with `id:[…]`; or filter by `author` or `changeType`). Do not combine exact IDs with filters. Recover with `undo_changes` / `redo_changes`.\n\n**Failures are safe.** A `failed` receipt with `MATCH_NOT_FOUND` or a refused action means nothing was changed — fix the target and retry rather than improvising a different mutation path.\n',
99765
+ "mcp-prompt.md": 'SuperDoc MCP server — read, edit, and save Word documents (.docx).\n\nIMPORTANT: Always use these superdoc tools for .docx files.\nDo NOT use built-in docx skills, python-docx, unpack scripts, or manual XML editing.\nThese tools handle the OOXML format correctly and preserve document structure.\n\n## Session lifecycle\n\n1. `superdoc_open({path: "/path/to/file.docx"})` — returns `session_id`. Opening a non-existent path creates a blank document.\n2. Pass `session_id` to every subsequent tool call.\n3. Read with `superdoc_inspect`, edit with `superdoc_perform_action`.\n4. `superdoc_save({session_id})` — writes changes to disk.\n5. `superdoc_close({session_id})` — releases the session. Always close when done.\n\n## Workflow\n\n**Inspect before you edit.** `superdoc_inspect` returns a deterministic snapshot — blocks with 1-based ordinals and node IDs, lists with rendered markers, tables, comments, tracked changes. Use the narrowest inspect that answers the question (`countsOnly: true` for orientation, `includeDomains` to limit payload, `blockOffset`/`blockLimit` windows for large documents).\n\n**Edit with named actions.** `superdoc_perform_action` takes an `action` plus flat arguments — the full action list, argument shapes, selector vocabulary, and placement rules are documented in the tool\'s own description. Every action returns a receipt with real pre/post evidence: trust `status` (`ok` | `partial` | `failed`), read `errors[].message` for recovery guidance, and re-inspect after `partial`.\n\n**Target one repeated match.** For `replace_text`, scope to one block with a selector and set `occurrence` on the edit (1 = first match in that block) to change only that match. Also pass `expectedRevision` from your latest inspect or receipt; on `REVISION_CONFLICT`, inspect again and recount.\n\n**Tracked changes (redlining).** Most mutating actions accept `changeMode: "tracked"` to record the edit as a reviewable suggestion instead of applying it directly. Review with `accept_tracked_changes` / `reject_tracked_changes` (target one change with `id`, or an exact set with `id:[…]`; or filter by `author` or `changeType`). Do not combine exact IDs with filters. Recover with `undo_changes` / `redo_changes`.\n\n**Failures are safe.** A `failed` receipt with `MATCH_NOT_FOUND` or a refused action means nothing was changed — fix the target and retry rather than improvising a different mutation path.\n',
99650
99766
  "system-prompt.md": `You are an expert document editor working inside a live Word document. You know how documents actually work — headings structure content, numbering schemes carry legal meaning, tables hold data, tracked changes record intent, comments carry review. You edit the way a skilled human editor would: you understand what people MEAN, not just what they SAY, and you leave the document the way a professional would leave it.
99651
99767
 
99652
99768
  ============================================================
@@ -99697,7 +99813,7 @@ ACTIONS (superdoc_perform_action with flat args)
99697
99813
  - undo_changes: untilMarker (rendered marker from the original state, e.g. "2.1.") or steps (1-25). Deterministic revert — steps history back until the marker reappears; the receipt proves it. NEVER use steps > 1 blindly: prefer untilMarker, and after ANY undo verify (superdoc_inspect) that content you meant to KEEP still exists — if you overshot, redo_changes {steps:N} steps forward again to recover it.
99698
99814
  - redo_changes: steps (1-25, default 1). Steps history FORWARD to re-apply edits a prior undo removed — THE recovery for an undo overshoot. Only reaches the forward branch until a NEW edit is made.
99699
99815
  - attach_numbering: anchorText (text of the block) or nodeId, likeMarker (rendered marker of a sibling clause, e.g. "10."). Makes an EXISTING block a numbered clause at the same scheme/level — "make this the section 11 heading" in a clause-numbered document is exactly this (it will render as the next number).
99700
- - replace_text: edits:[{find,replace}], caseSensitive default false. selector to scope. changeMode:"tracked" if asked. The receipt reports editsApplied and editsSkipped per find — READ IT: a skipped find is not in the selected block; re-target instead of assuming success.
99816
+ - replace_text: edits:[{find,replace,occurrence?}], caseSensitive default false. selector to scope. Keep find/replace spans MINIMAL. If the find appears more than once in the selected block and only one should change, give that edit occurrence (1 = first match in the block; needs selector; not selector.occurrence). With occurrence, also pass expectedRevision from your latest inspect or receipt; on REVISION_CONFLICT inspect again and recount. changeMode:"tracked" if asked. The receipt reports editsApplied and editsSkipped per find — READ IT: a skipped find is not in the selected block; re-target instead of assuming success.
99701
99817
  - delete_text: finds:[string]. selector to scope deletions to ONE block — REQUIRED for whitespace-only finds (unscoped whitespace matches document-wide and is refused). changeMode:"tracked" if asked. Deletes TEXT ONLY: the block survives, so a list item keeps its bullet/number and an accepted tracked deletion leaves an empty numbered item behind. To remove the item itself use delete_blocks.
99702
99818
  - delete_blocks: selectors:[…], each resolving to ONE block (list item, paragraph or heading). changeMode:"tracked" if asked. THE way to DELETE a whole LIST ITEM, paragraph or heading — the bullet/number goes with it and the remaining items renumber. "Delete the first item under Article II" / "remove that clause" is exactly this, NOT delete_text. Pass every target in ONE call. Use delete_table for a whole table.
99703
99819
  - rewrite_block: selector, text. Inspect first to gather current text; never ask the user to paste text already in the doc.
@@ -202071,6 +202187,7 @@ function commitResidentText(input, view) {
202071
202187
  return null;
202072
202188
  const range = { byteStart: paragraph.byteStart, byteEnd: paragraph.byteEnd };
202073
202189
  const collapsed = op.on.startOffset === op.on.endOffset;
202190
+ const leadingBookmarkBoundary = collapsed && op.newText.length > 0 && edit.visibleStart === 0 && edit.before.model.leaves().some((piece) => piece.kind === "marker" && piece.visibleStart === 0 && (piece.markerKind === "bookmarkStart" || piece.markerKind === "bookmarkEnd"));
202074
202191
  const requiresCollaborationSessionSync = op.changeMode === "direct" && op.on.coordinateSpace === "tracked" && edit.visibleStart !== op.on.startOffset;
202075
202192
  return finalizeCommit({
202076
202193
  input,
@@ -202095,7 +202212,8 @@ function commitResidentText(input, view) {
202095
202212
  blockId: op.on.startBlockId,
202096
202213
  startOffset: edit.visibleStart,
202097
202214
  text: op.newText,
202098
- runPropertiesXml: leaf.runProperties ?? ""
202215
+ runPropertiesXml: leaf.runProperties ?? "",
202216
+ ...leadingBookmarkBoundary ? { afterLeadingMarkers: true } : {}
202099
202217
  } } : {},
202100
202218
  ...edit.insertionRsid && !collapsed ? { trackedTextRefinement: {
202101
202219
  kind: "trackedTextRefinement",
@@ -385326,6 +385444,7 @@ class V2ListsAdapter {
385326
385444
  if (insertReceipt.status === "rejected") {
385327
385445
  return failure8(mapRejectionReason(insertReceipt.rejectedReason ?? "internal-error"), insertReceipt.rejectedReason ?? "list insert: paragraph insert rejected");
385328
385446
  }
385447
+ this.context.invalidate(insertReceipt);
385329
385448
  recordCommittedTxIdForHistory(this.context, insertReceipt.txId);
385330
385449
  const newParaId = readCreatedParagraphRefId(insertReceipt.createdRefs ?? [], insertReceipt.provisionalToFinal);
385331
385450
  if (!newParaId) {
@@ -385452,6 +385571,7 @@ class V2ListsAdapter {
385452
385571
  if (insertReceipt.status === "rejected") {
385453
385572
  return failure8(mapRejectionReason(insertReceipt.rejectedReason ?? "internal-error"), insertReceipt.rejectedReason ?? "list create: paragraph insert rejected");
385454
385573
  }
385574
+ this.context.invalidate(insertReceipt);
385455
385575
  recordCommittedTxIdForHistory(this.context, insertReceipt.txId);
385456
385576
  const newParaId = readCreatedParagraphRefId(insertReceipt.createdRefs ?? [], insertReceipt.provisionalToFinal);
385457
385577
  if (!newParaId) {
@@ -387183,6 +387303,7 @@ function applyListSeed(adapter, storyId, paraId, kind, expectedRevision, changeM
387183
387303
  if (receipt3.status === "rejected") {
387184
387304
  return failure8(mapRejectionReason(receipt3.rejectedReason ?? "internal-error"), receipt3.rejectedReason ?? "list create rejected");
387185
387305
  }
387306
+ adapter.context.invalidate(receipt3);
387186
387307
  recordCommittedTxIdForHistory(adapter.context, receipt3.txId);
387187
387308
  const summary = readListItemSummaryForSession(adapter.context.session, storyId, paraId);
387188
387309
  return {
@@ -419238,8 +419359,67 @@ async function compilePlan(context, input) {
419238
419359
  }
419239
419360
  detectTargetConflicts(context, steps, failures);
419240
419361
  detectUnsupportedRuntimeDrift(input.steps, failures);
419241
- detectTargetOffsetDriftRisk(steps, failures);
419242
- return { evaluatedRevision, steps, failures };
419362
+ const exactProgram = failures.length === 0 ? compileDescendingExactTargets(context, steps) : undefined;
419363
+ if (exactProgram) {
419364
+ if (exactProgram.edits.length > 0) {
419365
+ const preview = new V2SelectionMutationAdapter(context).executeResolvedTextRewrites(exactProgram.edits, { changeMode: changeMode ?? "direct", dryRun: true });
419366
+ if (!preview.success)
419367
+ failures.push({
419368
+ code: preview.failure.code,
419369
+ stepId: exactProgram.edits[0].ownerStepId,
419370
+ phase: "compile",
419371
+ message: preview.failure.message
419372
+ });
419373
+ }
419374
+ } else
419375
+ detectTargetOffsetDriftRisk(steps, failures);
419376
+ return {
419377
+ evaluatedRevision,
419378
+ steps,
419379
+ failures,
419380
+ ...exactProgram ? { atomicTextProgram: exactProgram } : {}
419381
+ };
419382
+ }
419383
+ function compileDescendingExactTargets(context, steps) {
419384
+ const mutating = steps.filter((step3) => step3.op !== "assert");
419385
+ if (mutating.length < 2)
419386
+ return;
419387
+ const edits = [];
419388
+ let blockId;
419389
+ let previousStart = Infinity;
419390
+ const index2 = getSDDocumentSessionInternalParagraphIndex(context.session);
419391
+ if (!index2)
419392
+ return;
419393
+ for (const step3 of mutating) {
419394
+ const source = step3.source;
419395
+ if (step3.op !== "text.rewrite" && step3.op !== "text.delete" || source.op === "assert" || !("where" in source) || source.where.by !== "target" || !step3.resolution || step3.request?.kind !== "replace")
419396
+ return;
419397
+ const target = source.where.target;
419398
+ if (target.kind !== "selection" || target.start.kind !== "text" || target.end.kind !== "text" || target.coordinateSpace === "tracked" || !isSupportedMutationStoryScope(target.story) || !isSupportedMutationStoryScope(target.start.story) || !isSupportedMutationStoryScope(target.end.story) || target.start.blockId !== target.end.blockId)
419399
+ return;
419400
+ blockId ?? (blockId = target.start.blockId);
419401
+ if (target.start.blockId !== blockId || step3.resolution.range.from >= step3.resolution.range.to || step3.resolution.range.to > previousStart)
419402
+ return;
419403
+ previousStart = step3.resolution.range.from;
419404
+ const on = resolveSelectionTargetToRange({
419405
+ target,
419406
+ index: index2,
419407
+ readPartBytes: (partUri) => getSDDocumentSessionInternalPartBytes(context.session, partUri),
419408
+ readPartRange: (partUri, start, end) => getSDDocumentSessionInternalPartRange(context.session, partUri, start, end),
419409
+ buildOffsetMap: buildOffsetMapForCoordinateSpace4
419410
+ });
419411
+ if ("error" in on)
419412
+ throw new DocumentApiValidationError2(on.error, on.message);
419413
+ if (step3.request.text !== step3.resolution.text)
419414
+ edits.push({
419415
+ on,
419416
+ newText: step3.request.text,
419417
+ resolution: step3.resolution,
419418
+ ownerStepId: step3.stepId,
419419
+ rewriteIntent: step3.op === "text.rewrite"
419420
+ });
419421
+ }
419422
+ return { edits };
419243
419423
  }
419244
419424
  function isOrderedAtomicTextProgram(steps) {
419245
419425
  return steps.length > 1 && steps.every((step3) => (step3.op === "text.rewrite" || step3.op === "text.delete") && step3.where.by === "select" && step3.where.select.type === "text" && step3.where.require === "all");
@@ -420836,6 +421016,7 @@ var init_engine3 = __esm(() => {
420836
421016
  init_sd_document_session_internal();
420837
421017
  init_search();
420838
421018
  init_raw_block_ref();
421019
+ init_selection_target();
420839
421020
  init_story_identity();
420840
421021
  init_story_surface();
420841
421022
  init_v2_search_ref();
@@ -475191,7 +475372,7 @@ function tryMirrorStoryText(prepared, extension2, options, admitted) {
475191
475372
  text: source.newText,
475192
475373
  txId: prepared.txId,
475193
475374
  directInsert: validation.directInsert,
475194
- preserveZeroLengthAtOffset: validation.inlineControl?.afterParagraphTail,
475375
+ preserveZeroLengthAtOffset: validation.directInsert.afterLeadingMarkers || validation.inlineControl?.afterParagraphTail,
475195
475376
  acceptedTextInsertionSplit: validation.acceptedTextInsertionSplit
475196
475377
  });
475197
475378
  } else {
@@ -475622,7 +475803,7 @@ function validateDirectTextInsert(source, extension2) {
475622
475803
  if (!directInsert) {
475623
475804
  return { ok: false, failureReason: "direct insert requires typed storySurface metadata" };
475624
475805
  }
475625
- if (directInsert.kind !== "directTextInsert" || directInsert.blockId !== source.on.startBlockId || directInsert.startOffset !== source.on.startOffset || directInsert.text !== source.newText) {
475806
+ if (directInsert.kind !== "directTextInsert" || directInsert.blockId !== source.on.startBlockId || directInsert.startOffset !== source.on.startOffset || directInsert.text !== source.newText || directInsert.afterLeadingMarkers !== undefined && (directInsert.afterLeadingMarkers !== true || directInsert.startOffset !== 0)) {
475626
475807
  return { ok: false, failureReason: "direct insert storySurface metadata does not match source" };
475627
475808
  }
475628
475809
  if (parsePreparedRunAttributes(directInsert.runPropertiesXml) === null) {
@@ -485392,8 +485573,12 @@ function jsonSchemaPropertyToZod(prop, definitions = {}, resolvingReferences = n
485392
485573
  return describe3(intersection3);
485393
485574
  }
485394
485575
  switch (type) {
485395
- case "string":
485396
- return describe3(exports_external.string());
485576
+ case "string": {
485577
+ let schema = exports_external.string();
485578
+ if (typeof prop.minLength === "number")
485579
+ schema = schema.min(prop.minLength);
485580
+ return describe3(schema);
485581
+ }
485397
485582
  case "number": {
485398
485583
  let schema = exports_external.number();
485399
485584
  if (typeof prop.exclusiveMinimum === "number")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superdoc/mcp",
3
- "version": "1.9.0-next.12",
3
+ "version": "1.9.0-next.13",
4
4
  "description": "Model Context Protocol server for SuperDoc, giving an AI agent tools to read and edit .docx files.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -24,13 +24,13 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "@modelcontextprotocol/sdk": "^1.26.0",
27
- "@superdoc/sdk": "2.22.0-next.12",
27
+ "@superdoc/sdk": "2.22.0-next.13",
28
28
  "debug": "^4.4.3",
29
29
  "zod": "^4.3.6"
30
30
  },
31
31
  "devDependencies": {
32
- "superdoc": "2.25.0-next.12",
33
- "@superdoc/docx-engine": "0.24.0-next.12",
32
+ "superdoc": "2.25.0-next.13",
33
+ "@superdoc/docx-engine": "0.24.0-next.13",
34
34
  "@superdoc/document-api": "0.1.0-alpha.0",
35
35
  "@types/bun": "^1.3.8",
36
36
  "@types/node": "22.19.2",