typst-mcp 1.1.0 → 1.3.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.
@@ -0,0 +1,61 @@
1
+ import { createHash } from "node:crypto";
2
+ export function hashText(text) {
3
+ return createHash("sha256").update(text, "utf8").digest("hex");
4
+ }
5
+ export function validateReadOptions(options) {
6
+ for (const key of ["offset", "limit", "maxLines", "maxBytes"]) {
7
+ const value = options[key];
8
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
9
+ throw new Error(`${key} must be a positive integer.`);
10
+ }
11
+ }
12
+ if (options.maxBytes !== undefined && options.maxBytes > 1024 * 1024) {
13
+ throw new Error("maxBytes must not exceed 1048576 (1 MiB). Read smaller ranges.");
14
+ }
15
+ if (options.lineNumbers !== undefined && typeof options.lineNumbers !== "boolean") {
16
+ throw new Error("lineNumbers must be a boolean.");
17
+ }
18
+ }
19
+ /** Return only complete source lines. Continuation metadata never enters source text. */
20
+ export function readTextContent(text, options = {}) {
21
+ validateReadOptions(options);
22
+ const lines = text.match(/[^\n]*\n|[^\n]+/g) ?? [];
23
+ const totalLines = lines.length;
24
+ const offset = options.offset ?? 1;
25
+ if (offset > Math.max(1, totalLines)) {
26
+ throw new Error(`Offset ${offset} is beyond end of file (${totalLines} lines total)`);
27
+ }
28
+ const maxLines = Math.min(options.limit ?? 200, options.maxLines ?? Infinity);
29
+ const maxBytes = options.maxBytes ?? 16 * 1024;
30
+ const selected = [];
31
+ let bytes = 0;
32
+ let truncatedBy = "lines";
33
+ const stop = Math.min(totalLines, offset - 1 + maxLines);
34
+ const width = String(stop).length;
35
+ for (let index = offset - 1; index < stop; index++) {
36
+ const line = lines[index];
37
+ const rendered = options.lineNumbers
38
+ ? `${String(index + 1).padStart(width, " ")} | ${line}` : line;
39
+ const lineBytes = Buffer.byteLength(rendered, "utf8");
40
+ if (bytes + lineBytes > maxBytes) {
41
+ if (selected.length === 0) {
42
+ throw new Error(`Line ${offset} needs ${lineBytes} bytes. Increase maxBytes (up to 1048576) or read another line.`);
43
+ }
44
+ truncatedBy = "bytes";
45
+ break;
46
+ }
47
+ selected.push(rendered);
48
+ bytes += lineBytes;
49
+ }
50
+ const endLine = offset - 1 + selected.length;
51
+ const truncated = endLine < totalLines;
52
+ return {
53
+ content: selected.join(""),
54
+ contentHash: hashText(text),
55
+ totalLines,
56
+ startLine: offset,
57
+ endLine,
58
+ truncated,
59
+ ...(truncated ? { nextOffset: endLine + 1, truncatedBy } : {}),
60
+ };
61
+ }
@@ -57,41 +57,118 @@ export function searchInContent(filePath, content, options) {
57
57
  }
58
58
  return { matches, totalMatches };
59
59
  }
60
- export function replaceInContent(filePath, content, options) {
61
- let flags = "g";
62
- if (options.ignoreCase)
63
- flags += "i";
64
- let searchRegex;
65
- if (options.regex) {
66
- searchRegex = new RegExp(options.pattern, flags);
60
+ /** Validate the entire request before any project files can be mutated. */
61
+ export function resolveReplacementRules(options) {
62
+ const hasSingle = options.pattern !== undefined || options.replacement !== undefined;
63
+ if (options.replacements !== undefined && hasSingle) {
64
+ throw new Error("Use either replacements or pattern/replacement, not both.");
67
65
  }
68
- else {
69
- const escaped = options.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
70
- searchRegex = new RegExp(escaped, flags);
66
+ if (options.output !== undefined && !["summary", "diff", "full"].includes(options.output)) {
67
+ throw new Error("output must be summary, diff, or full.");
68
+ }
69
+ if (options.replacements !== undefined &&
70
+ (options.regex !== undefined || options.ignoreCase !== undefined || options.limit !== undefined)) {
71
+ throw new Error("For batch replacements, set regex, ignoreCase, and limit on each rule.");
72
+ }
73
+ const rules = options.replacements ?? [{
74
+ pattern: options.pattern, replacement: options.replacement,
75
+ regex: options.regex, ignoreCase: options.ignoreCase, limit: options.limit,
76
+ }];
77
+ if (!Array.isArray(rules) || rules.length === 0) {
78
+ throw new Error("replacements must be a non-empty array.");
79
+ }
80
+ for (const [index, rule] of rules.entries()) {
81
+ if (!rule || typeof rule.pattern !== "string" || !rule.pattern.length ||
82
+ typeof rule.replacement !== "string") {
83
+ throw new Error(`Replacement ${index + 1} requires a non-empty pattern and a string replacement.`);
84
+ }
85
+ for (const key of ["regex", "ignoreCase"]) {
86
+ if (rule[key] !== undefined && typeof rule[key] !== "boolean") {
87
+ throw new Error(`Replacement ${index + 1}: ${key} must be a boolean.`);
88
+ }
89
+ }
90
+ if (rule.limit !== undefined && (!Number.isInteger(rule.limit) || rule.limit < 0)) {
91
+ throw new Error(`Replacement ${index + 1}: limit must be a non-negative integer.`);
92
+ }
93
+ if (rule.regex) {
94
+ try {
95
+ new RegExp(rule.pattern, rule.ignoreCase ? "gi" : "g");
96
+ }
97
+ catch {
98
+ throw new Error(`Replacement ${index + 1} has an invalid regular expression.`);
99
+ }
100
+ }
71
101
  }
72
- let count = 0;
73
- const limit = options.limit;
74
- const updatedText = content.replace(searchRegex, (match, ...args) => {
75
- if (limit !== undefined && count >= limit) {
76
- return match;
102
+ return rules;
103
+ }
104
+ export function replaceInContent(filePath, content, options) {
105
+ const result = replaceBatchInContent(filePath, content, [options]);
106
+ return { ...result, diff: result.diff, patch: result.patch };
107
+ }
108
+ /** Rules run in order on the result of the previous rule. Diff covers the whole batch. */
109
+ export function replaceBatchInContent(filePath, content, rules, output = "full") {
110
+ resolveReplacementRules({ replacements: rules, output });
111
+ let updatedText = content;
112
+ const replacementCounts = [];
113
+ for (const options of rules) {
114
+ let flags = "g";
115
+ if (options.ignoreCase)
116
+ flags += "i";
117
+ let searchRegex;
118
+ if (options.regex) {
119
+ searchRegex = new RegExp(options.pattern, flags);
77
120
  }
78
- count++;
79
- if (!options.regex) {
80
- return options.replacement;
121
+ else {
122
+ const escaped = options.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
123
+ searchRegex = new RegExp(escaped, flags);
81
124
  }
82
- // For regex, support standard replacement with capture groups ($1, $2)
83
- return options.replacement.replace(/\$(\d+)/g, (_, groupIdx) => {
84
- const idx = parseInt(groupIdx, 10);
85
- return args[idx - 1] ?? "";
125
+ let count = 0;
126
+ const limit = options.limit;
127
+ updatedText = updatedText.replace(searchRegex, (match, ...args) => {
128
+ if (limit !== undefined && count >= limit) {
129
+ return match;
130
+ }
131
+ count++;
132
+ if (!options.regex) {
133
+ return options.replacement;
134
+ }
135
+ // The final callback arguments are offset/input and optional named groups,
136
+ // not captures. Implement JS replacement tokens without exposing metadata.
137
+ const hasNamedGroups = typeof args[args.length - 1] === "object";
138
+ const groups = hasNamedGroups ? args[args.length - 1] : undefined;
139
+ const input = args[args.length - (hasNamedGroups ? 2 : 1)];
140
+ const offset = args[args.length - (hasNamedGroups ? 3 : 2)];
141
+ const captures = args.slice(0, hasNamedGroups ? -3 : -2);
142
+ return options.replacement.replace(/\$(\$|&|`|'|\d{1,2}|<[^>]*>)/g, (token, key) => {
143
+ if (key === "$")
144
+ return "$";
145
+ if (key === "&")
146
+ return match;
147
+ if (key === "`")
148
+ return input.slice(0, offset);
149
+ if (key === "'")
150
+ return input.slice(offset + match.length);
151
+ if (key.startsWith("<"))
152
+ return groups ? groups[key.slice(1, -1)] ?? "" : token;
153
+ const index = Number(key);
154
+ if (index > 0 && index <= captures.length)
155
+ return captures[index - 1] ?? "";
156
+ const first = Number(key[0]);
157
+ if (key.length === 2 && first > 0 && first <= captures.length) {
158
+ return (captures[first - 1] ?? "") + key[1];
159
+ }
160
+ return token;
161
+ });
86
162
  });
87
- });
163
+ replacementCounts.push(count);
164
+ }
88
165
  const diffResult = generateDiffString(content, updatedText);
89
- const patch = generateUnifiedPatch(filePath, content, updatedText);
90
166
  return {
91
167
  updatedText,
92
- replacementCount: count,
93
- diff: diffResult.diff,
94
- patch,
168
+ replacementCount: replacementCounts.reduce((sum, count) => sum + count, 0),
169
+ replacementCounts,
170
+ ...(output !== "summary" ? { diff: diffResult.diff } : {}),
171
+ ...(output === "full" ? { patch: generateUnifiedPatch(filePath, content, updatedText) } : {}),
95
172
  firstChangedLine: diffResult.firstChangedLine,
96
173
  };
97
174
  }
@@ -1,6 +1,5 @@
1
1
  import * as Y from "yjs";
2
- import * as Diff from "diff";
3
- import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, splitBom, } from "./edit-diff.js";
2
+ import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, splitBom, validateEdits, } from "./edit-diff.js";
4
3
  function computeUpdateDiff(doc, remoteUpdate) {
5
4
  let stateVector;
6
5
  if (remoteUpdate.byteLength > 0) {
@@ -79,10 +78,8 @@ export function replaceYjsText(remoteUpdate, newContent) {
79
78
  * directly on the `Y.Text("text")` CRDT, with CRLF/BOM resilience, fuzzy matching,
80
79
  * and unified diff generation.
81
80
  */
82
- export function editYjsText(remoteUpdate, edits, filePath = "file") {
83
- if (edits.length === 0) {
84
- throw new Error("At least one edit operation is required");
85
- }
81
+ export function editYjsText(remoteUpdate, edits, filePath = "file", matchMode = "fuzzy") {
82
+ validateEdits(edits);
86
83
  const doc = new Y.Doc();
87
84
  try {
88
85
  if (remoteUpdate.byteLength > 0) {
@@ -93,23 +90,50 @@ export function editYjsText(remoteUpdate, edits, filePath = "file") {
93
90
  const { bom, text: contentWithoutBom } = splitBom(previousText);
94
91
  const originalEnding = detectLineEnding(contentWithoutBom);
95
92
  const normalizedContent = normalizeToLF(contentWithoutBom);
96
- const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, filePath);
97
- const finalContent = bom + restoreLineEndings(newContent, originalEnding);
98
- // Apply minimal surgical character changes to Y.Text
99
- const diffs = Diff.diffChars(previousText, finalContent);
93
+ const { baseContent, newContent, matchedEdits, usedFuzzyMatch } = applyEditsToNormalizedContent(normalizedContent, edits, filePath, matchMode);
94
+ // Map LF-normalized match boundaries back to original UTF-16 offsets.
95
+ // Preserve all untouched characters, including mixed CRLF/LF line endings.
96
+ const offsets = [0];
97
+ for (let i = 0; i < contentWithoutBom.length; i++) {
98
+ if (contentWithoutBom[i] === "\r" && contentWithoutBom[i + 1] === "\n")
99
+ i++;
100
+ offsets.push(i + 1);
101
+ }
102
+ const rawEdits = [...matchedEdits].reverse().map(edit => {
103
+ const start = offsets[edit.matchIndex];
104
+ const end = offsets[edit.matchIndex + edit.matchLength];
105
+ return { start, end, newText: restoreLineEndings(edit.newText, originalEnding) };
106
+ });
107
+ let updatedContent = contentWithoutBom;
108
+ for (const edit of rawEdits) {
109
+ updatedContent = updatedContent.slice(0, edit.start) + edit.newText + updatedContent.slice(edit.end);
110
+ }
111
+ const finalContent = bom + updatedContent;
112
+ // Use known edit ranges instead of diffing the entire document character by
113
+ // character. Retain each anchor's unchanged prefix/suffix and their CRDT IDs.
100
114
  doc.transact(() => {
101
- let offset = 0;
102
- for (const part of diffs) {
103
- if (part.added) {
104
- ytext.insert(offset, part.value);
105
- offset += part.value.length;
106
- }
107
- else if (part.removed) {
108
- ytext.delete(offset, part.value.length);
109
- }
110
- else {
111
- offset += part.value.length;
112
- }
115
+ for (const edit of rawEdits) {
116
+ const old = contentWithoutBom.slice(edit.start, edit.end);
117
+ let prefix = 0;
118
+ while (prefix < old.length && prefix < edit.newText.length && old[prefix] === edit.newText[prefix])
119
+ prefix++;
120
+ // Never split surrogate pairs when trimming a common prefix/suffix.
121
+ if (prefix > 0 && /[\uD800-\uDBFF]/.test(old[prefix - 1]) && /[\uDC00-\uDFFF]/.test(old[prefix] ?? ""))
122
+ prefix--;
123
+ let suffix = 0;
124
+ while (suffix < old.length - prefix && suffix < edit.newText.length - prefix &&
125
+ old[old.length - 1 - suffix] === edit.newText[edit.newText.length - 1 - suffix])
126
+ suffix++;
127
+ if (suffix > 0 && /[\uDC00-\uDFFF]/.test(old[old.length - suffix]) &&
128
+ /[\uD800-\uDBFF]/.test(old[old.length - suffix - 1] ?? ""))
129
+ suffix--;
130
+ const index = bom.length + edit.start + prefix;
131
+ const removed = old.length - prefix - suffix;
132
+ const inserted = edit.newText.slice(prefix, edit.newText.length - suffix);
133
+ if (removed)
134
+ ytext.delete(index, removed);
135
+ if (inserted)
136
+ ytext.insert(index, inserted);
113
137
  }
114
138
  });
115
139
  const { encodedUpdate, mergedState } = computeUpdateDiff(doc, remoteUpdate);
@@ -124,6 +148,7 @@ export function editYjsText(remoteUpdate, edits, filePath = "file") {
124
148
  diff: diffResult.diff,
125
149
  patch,
126
150
  firstChangedLine: diffResult.firstChangedLine,
151
+ usedFuzzyMatch,
127
152
  };
128
153
  }
129
154
  finally {
@@ -122,7 +122,7 @@ export const TYPST_MCP_TOOLS = [
122
122
  },
123
123
  {
124
124
  name: "typst_read_file",
125
- description: "Read a file from a https://typst.app project. Supports text files (.typ, .bib, .toml, etc.) with offset/limit pagination, optional line numbering, and automatic truncation safeguards (2,000 lines or 50KB). Binary image assets (PNG, JPEG, SVG, WebP, GIF, BMP) are returned with native multimodal MCP image content blocks.",
125
+ description: "Read project text in bounded pages (default 200 lines/16 KiB) or images as native image blocks. content contains source only; use nextOffset for continuation. contentHash covers the entire raw text file: pass it as expectedHash to typst_edit_file. Compact file metadata by default; output='full' adds metadata, not extra source lines. Use grep to locate relevant lines before reading large files.",
126
126
  inputSchema: {
127
127
  type: "object",
128
128
  properties: {
@@ -135,12 +135,22 @@ export const TYPST_MCP_TOOLS = [
135
135
  description: "File path/name (e.g. 'main.typ') or file ID",
136
136
  },
137
137
  offset: {
138
- type: "number",
138
+ type: "integer",
139
+ minimum: 1,
139
140
  description: "Line number to start reading from (1-indexed)",
140
141
  },
141
142
  limit: {
142
- type: "number",
143
- description: "Maximum number of lines to read",
143
+ type: "integer",
144
+ minimum: 1,
145
+ description: "Maximum complete lines to read (default: 200). Use nextOffset to continue.",
146
+ },
147
+ maxBytes: {
148
+ type: "integer", minimum: 1, maximum: 1048576,
149
+ description: "Maximum returned source bytes, including line number prefixes (default: 16384, maximum: 1 MiB). Increase for unusually long lines.",
150
+ },
151
+ output: {
152
+ type: "string", enum: ["compact", "full"],
153
+ description: "compact (default): minimal file metadata; full: all file metadata. Both use offset/limit/maxBytes pagination.",
144
154
  },
145
155
  lineNumbers: {
146
156
  type: "boolean",
@@ -178,7 +188,7 @@ export const TYPST_MCP_TOOLS = [
178
188
  },
179
189
  {
180
190
  name: "typst_edit_file",
181
- description: "Apply one or more surgical text replacements (oldText -> newText) directly to a multiplayer file's Yjs CRDT document in a https://typst.app project. Supports CRLF/LF resilience, UTF-8 BOM preservation, fuzzy matching for Unicode quotes/dashes/whitespace, and returns a unified diff and patch of the changes.",
191
+ description: "Apply all targeted edits against one original file snapshot, with one save. Prefer short unique oldText anchors and expectedHash from typst_read_file to reject stale edits. Exact matching by default (LF/CRLF resilient); matchMode='fuzzy' opts into Unicode/whitespace fallback. Malformed, ambiguous, or overlapping edits fail without saving. Summary output by default; dryRun previews a bounded diff. Unchanged characters and BOM are preserved.",
182
192
  inputSchema: {
183
193
  type: "object",
184
194
  properties: {
@@ -192,13 +202,15 @@ export const TYPST_MCP_TOOLS = [
192
202
  },
193
203
  edits: {
194
204
  type: "array",
205
+ minItems: 1,
195
206
  description: "One or more targeted non-overlapping replacements. Each oldText must be unique in the original file.",
196
207
  items: {
197
208
  type: "object",
198
209
  properties: {
199
210
  oldText: {
200
211
  type: "string",
201
- description: "Unique substring to replace (matches exact or normalized quotes/dashes)",
212
+ minLength: 1,
213
+ description: "Unique source substring to replace. All edits match the original file, not earlier edits' results.",
202
214
  },
203
215
  newText: {
204
216
  type: "string",
@@ -216,12 +228,36 @@ export const TYPST_MCP_TOOLS = [
216
228
  type: "string",
217
229
  description: "Optional replacement text for single replacement shorthand",
218
230
  },
231
+ expectedHash: {
232
+ type: "string", pattern: "^[a-fA-F0-9]{64}$",
233
+ description: "Recommended: contentHash from the latest read. Checked against fresh downloaded text inside the file mutation queue; stale files fail without saving.",
234
+ },
235
+ matchMode: {
236
+ type: "string", enum: ["exact", "fuzzy"],
237
+ description: "exact (default) matches source with newline normalization. fuzzy enables Unicode quote/dash/space/NFKC fallback only when an exact match is absent.",
238
+ },
239
+ dryRun: {
240
+ type: "boolean",
241
+ description: "Validate and preview edits without saving (default: false). Returned contentHash still identifies unchanged cloud text.",
242
+ },
243
+ output: {
244
+ type: "string", enum: ["summary", "diff", "full"],
245
+ description: "summary (default): counts, new contentHash, firstChangedLine; diff: also a bounded display diff; full: also bounded patch and full metadata. dryRun defaults to diff.",
246
+ },
247
+ maxDiffBytes: {
248
+ type: "integer", minimum: 1, maximum: 1048576,
249
+ description: "Byte budget per diff/patch field (default: 8192). Each also has a 100-line cap. Truncation is explicit; read affected ranges for full details.",
250
+ },
219
251
  preferWebSocket: {
220
252
  type: "boolean",
221
253
  description: "Push real-time TextUpdate over /editing/v3 WebSocket in addition to REST persistence (default: true)",
222
254
  },
223
255
  },
224
256
  required: ["projectId", "filePath"],
257
+ oneOf: [
258
+ { required: ["edits"], not: { anyOf: [{ required: ["oldText"] }, { required: ["newText"] }] } },
259
+ { required: ["oldText", "newText"], not: { required: ["edits"] } },
260
+ ],
225
261
  },
226
262
  },
227
263
  {
@@ -296,7 +332,7 @@ export const TYPST_MCP_TOOLS = [
296
332
  },
297
333
  {
298
334
  name: "typst_get_diagnostics",
299
- description: "Synchronize all files from a https://typst.app project and run the Typst compiler to retrieve structured errors, warnings, line/column numbers, and hints.",
335
+ description: "Retrieve compiler diagnostics (errors, warnings, line/column numbers, and hints) for a https://typst.app project. Supports both local Typst CLI compilation (fastest) and Typst Web cloud diagnostics (zero-binary fallback using typst.app cloud compiler).",
300
336
  inputSchema: {
301
337
  type: "object",
302
338
  properties: {
@@ -308,6 +344,11 @@ export const TYPST_MCP_TOOLS = [
308
344
  type: "string",
309
345
  description: "Optional main .typ entry file override (defaults to project previewed file or main.typ)",
310
346
  },
347
+ mode: {
348
+ type: "string",
349
+ enum: ["auto", "cli", "web"],
350
+ description: "Diagnostics mode: 'auto' (prefers local Typst CLI if installed, falls back to Typst Web cloud compiler), 'cli' (requires local typst binary), or 'web' (uses Typst Web cloud compiler). Defaults to 'auto'.",
351
+ },
311
352
  },
312
353
  required: ["projectId"],
313
354
  },
@@ -417,7 +458,7 @@ export const TYPST_MCP_TOOLS = [
417
458
  },
418
459
  {
419
460
  name: "typst_find_and_replace",
420
- description: "Find and replace text or regular expressions across a single file or all files in a https://typst.app project. Supports dry-run preview diffs, regex capture groups, and per-file mutation queue serialization.",
461
+ description: "Find and replace text or regex in one file or project-wide. Use replacements to batch mappings (e.g. Sep → September, Oct → October) in one call with one save per file. Rules run sequentially; later rules see earlier results. Batch output defaults to compact summary; request output='diff' or 'full' for previews. Supports dryRun and per-file mutation serialization. Project-wide changes are not atomic across files.",
421
462
  inputSchema: {
422
463
  type: "object",
423
464
  properties: {
@@ -433,6 +474,27 @@ export const TYPST_MCP_TOOLS = [
433
474
  type: "string",
434
475
  description: "Replacement text (supports $1, $2 capture groups if regex is true)",
435
476
  },
477
+ replacements: {
478
+ type: "array",
479
+ minItems: 1,
480
+ description: "Ordered replacement rules instead of pattern/replacement. Each rule runs on the previous result. Set regex, ignoreCase, and limit per rule. All rules are validated before any writes; each changed file is saved once.",
481
+ items: {
482
+ type: "object",
483
+ properties: {
484
+ pattern: { type: "string", minLength: 1 },
485
+ replacement: { type: "string" },
486
+ regex: { type: "boolean", description: "Treat pattern as regex (default: false)" },
487
+ ignoreCase: { type: "boolean", description: "Case-insensitive matching (default: false)" },
488
+ limit: { type: "integer", minimum: 0, description: "Maximum replacements for this rule per file" },
489
+ },
490
+ required: ["pattern", "replacement"],
491
+ },
492
+ },
493
+ output: {
494
+ type: "string",
495
+ enum: ["summary", "diff", "full"],
496
+ description: "summary: counts and first changed line; diff: also display diff; full: also unified patch. Defaults to summary for batches, full for legacy pattern/replacement calls. Applies to dry runs too.",
497
+ },
436
498
  filePath: {
437
499
  type: "string",
438
500
  description: "Optional file path/name (e.g. 'main.typ'). If omitted, searches and replaces across all text files in the project.",
@@ -462,7 +524,11 @@ export const TYPST_MCP_TOOLS = [
462
524
  description: "Push real-time TextUpdate over /editing/v3 WebSocket before REST persistence (default: true)",
463
525
  },
464
526
  },
465
- required: ["projectId", "pattern", "replacement"],
527
+ required: ["projectId"],
528
+ oneOf: [
529
+ { required: ["pattern", "replacement"], not: { required: ["replacements"] } },
530
+ { required: ["replacements"], not: { anyOf: [{ required: ["pattern"] }, { required: ["replacement"] }, { required: ["regex"] }, { required: ["ignoreCase"] }, { required: ["limit"] }] } },
531
+ ],
466
532
  },
467
533
  },
468
534
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typst-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Model Context Protocol (MCP) server for https://typst.app/ cloud projects, Yjs real-time text editing, asset management, and Typst compiler diagnostics",
5
5
  "keywords": [
6
6
  "mcp",