typst-mcp 1.2.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.
package/README.md CHANGED
@@ -13,48 +13,44 @@
13
13
 
14
14
  ---
15
15
 
16
- ## Quick Start
16
+ ## Installation
17
+
18
+ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`, `claude_desktop_config.json`, or `.cursor/mcp.json`).
17
19
 
18
20
  ### Prerequisites
19
21
 
20
- - [**Node.js**](https://nodejs.org/) `>= 18` (for `npx` install) or [**Bun**](https://bun.sh) `>= 1.2` (for local development)
22
+ - [**Node.js**](https://nodejs.org/) `>= 18`
21
23
  - [**Typst CLI**](https://github.com/typst/typst) (optional: `typst` on `PATH` for local CLI compilation speed; if not installed, `typst_get_diagnostics` automatically uses Typst Web cloud compiler diagnostics)
22
24
 
23
- ### Installation
24
-
25
- **Option 1: Quick start (no clone, no global install):**
26
-
27
- ```bash
28
- npx -y typst-mcp
29
- ```
30
-
31
- **Option 2: Always run latest release (bypasses local `npx` cache):**
32
-
33
- ```bash
34
- npx -y typst-mcp@latest
35
- ```
36
-
37
- Runs the published [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) package (Node `>= 18`). `-y` skips the npx confirmation prompt.
25
+ ### Option 1: Normal (Standard)
38
26
 
39
- **Option 3: From source (development):**
27
+ Runs the published [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) package (`npx -y typst-mcp`):
40
28
 
41
- ```bash
42
- git clone https://github.com/caphefalumi/typst-web-mcp.git
43
- cd typst-web-mcp
44
- bun install
45
- bun run dev
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "typst-mcp": {
33
+ "command": "npx",
34
+ "args": ["-y", "typst-mcp"],
35
+ "env": {
36
+ "TYPST_EMAIL": "your-email@example.com",
37
+ "TYPST_PASSWORD": "your-password"
38
+ }
39
+ }
40
+ }
41
+ }
46
42
  ```
47
43
 
48
- ### MCP Client Configuration
44
+ ### Option 2: Always Latest
49
45
 
50
- Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`, `claude_desktop_config.json`, or `.cursor/mcp.json`):
46
+ Bypasses local `npx` cache to automatically pull and run the newest release on launch (`npx -y typst-mcp@latest`):
51
47
 
52
48
  ```json
53
49
  {
54
50
  "mcpServers": {
55
51
  "typst-mcp": {
56
52
  "command": "npx",
57
- "args": ["-y", "typst-mcp"],
53
+ "args": ["-y", "typst-mcp@latest"],
58
54
  "env": {
59
55
  "TYPST_EMAIL": "your-email@example.com",
60
56
  "TYPST_PASSWORD": "your-password"
@@ -64,10 +60,6 @@ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`,
64
60
  }
65
61
  ```
66
62
 
67
- > **Tip:** You can also set `"args": ["-y", "typst-mcp@latest"]` to guarantee your MCP client automatically pulls and runs the newest version on launch.
68
-
69
- > **Session Persistence:** Once authenticated via `typst_auth_login` (or environment variables), the `__Http-typst-session` cookie is cached locally at `~/.pi/agent/typst-mcp-session.json` so subsequent tool calls authenticate automatically without re-signing in.
70
-
71
63
  ---
72
64
 
73
65
  ## Overview
@@ -93,11 +85,11 @@ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`,
93
85
  | **Projects** | `typst_create_project` | Create a new `typst.app` cloud project with initial `.typ`/`.bib` files and pinned compiler version. |
94
86
  | **Projects** | `typst_delete_project` | Permanently delete a `typst.app` project by ID (`DeleteProjectMutation`). |
95
87
  | **Files & CRDT** | `typst_list_files` | List all multiplayer Yjs text files and binary asset blobs in a project. |
96
- | **Files & CRDT** | `typst_read_file` | Read any project file with offset/limit pagination, optional line numbering, truncation limits (2,000 lines/50KB), and multimodal MCP image content blocks (`PNG`, `JPG`, `SVG`, `WebP`). |
88
+ | **Files & CRDT** | `typst_read_file` | Read bounded source pages (200 lines/16 KiB by default), with compact metadata, whole-file content hashes, separate continuation fields, and native image blocks. |
97
89
  | **Files & CRDT** | `typst_write_file` | Create or overwrite a `.typ`/`.bib`/`.toml` file, protected by per-file mutation queue serialization, syncing Yjs state diffs over `/editing/v3` WebSocket and REST. |
98
- | **Files & CRDT** | `typst_edit_file` | Apply surgical `oldText -> newText` replacements directly to the `Y.Text("text")` CRDT with CRLF/BOM resilience, fuzzy quote/dash matching, unified diff & patch output, and per-file mutation serialization. |
90
+ | **Files & CRDT** | `typst_edit_file` | Apply validated, unique edits to one snapshot with optional stale-content protection, exact matching by default, compact receipts, opt-in fuzzy fallback, and bounded diff previews. |
99
91
  | **Search & Replace** | `typst_grep_project` | Search for text or regular expressions across project files, returning file paths, line numbers, and optional surrounding context lines. |
100
- | **Search & Replace** | `typst_find_and_replace` | Find and replace text or regex across a single file or project-wide with regex capture groups (`$1`), dry-run preview diffs, and per-file mutation serialization. |
92
+ | **Search & Replace** | `typst_find_and_replace` | Replace text or regex in one file or project-wide. Batch ordered replacement rules in one call with one save per changed file, compact summaries, optional diffs, and dry-run previews. |
101
93
  | **Assets & Tree** | `typst_upload_asset` | Upload or replace binary assets (`PNG`, `JPG`, `SVG`, `PDF`, fonts) from a local path or base64 payload. |
102
94
  | **Assets & Tree** | `typst_rename_file` | Rename or move a file inside a `typst.app` project (`UpdatesFileMutation`). |
103
95
  | **Assets & Tree** | `typst_delete_file` | Delete a file or binary asset from a project (`DeleteFilesMutation`). |
@@ -107,8 +99,96 @@ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`,
107
99
 
108
100
  ---
109
101
 
102
+ ## Read and edit efficiently
103
+
104
+ 1. Use `typst_grep_project` to locate relevant text, then read just that range with `typst_read_file` (`offset` is 1-based, `limit` defaults to 200 lines, `maxBytes` defaults to 16 KiB and can be raised to 1 MiB).
105
+ 2. Keep the returned `contentHash`. It is SHA-256 of the **entire raw text file**, including BOM and line endings, even when reading a range or displaying line numbers.
106
+ 3. Send all disjoint edits in one `typst_edit_file` call, with `expectedHash` set to that hash. Prefer a short unique `oldText` anchor over repeating a whole file.
107
+
108
+ ```json
109
+ {
110
+ "projectId": "your-project-id",
111
+ "filePath": "main.typ",
112
+ "expectedHash": "<contentHash from the read response>",
113
+ "edits": [
114
+ { "oldText": "September 2025", "newText": "September 2026" }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ Text read responses include source only in `content`, with `totalLines`, `startLine`, `endLine`, `truncated`, and `nextOffset` when more source remains. They never repeat source in truncation metadata or append instructions to it. Complete lines and original line endings are retained, so unnumbered pages can be concatenated without losing bytes. A trailing newline does not create a phantom extra line. An empty file has `totalLines: 0`, `startLine: 1`, `endLine: 0`.
120
+
121
+ Read metadata defaults to `{id, name}`. Use `output: "full"` for full file metadata; this does not increase the source range. `lineNumbers: true` adds display prefixes: remove those prefixes when constructing `oldText`. Byte budgets include those prefixes. If one line cannot fit, the tool returns an actionable error rather than an empty page that cannot advance.
122
+
123
+ Edits match against the same original snapshot, not the result of previous edits. All entries must be valid, and each anchor must be unique, including overlapping occurrences. Invalid batches, overlapping ranges, and stale hashes fail before saving. `matchMode: "exact"` is the default and accepts LF/CRLF differences. Use `matchMode: "fuzzy"` to opt into Unicode quote/dash/space and NFKC fallback when no exact match exists. Fallback matching preserves characters outside the matched span. Edits retain the BOM and untouched line endings, including mixed LF/CRLF files.
124
+
125
+ Successful edits return a compact receipt with file ID/name, `appliedCount`, the resulting `contentHash`, and `firstChangedLine`. This avoids returning the same changes as both a diff and a patch. Optional controls:
126
+
127
+ - `output: "diff"` adds a display diff; `output: "full"` also adds a patch and full metadata.
128
+ - Each diff/patch field is limited to 100 lines and `maxDiffBytes` bytes (default 8192, maximum 1 MiB). `diffTruncated` / `patchTruncated` explicitly flag omissions; read the affected range for complete source.
129
+ - `dryRun: true` validates and previews without saving and defaults to diff output. Its `appliedCount` describes proposed edits; its `contentHash` still identifies the unchanged cloud text.
130
+
131
+ `expectedHash` is checked on fresh text inside the shared file mutation queue, which serializes local edits, replacements, and whole-file writes to existing files. It detects changes made since the read; it is not a cloud compare-and-swap transaction and cannot prevent a collaborator from editing during the subsequent save. Yjs still merges concurrent cloud updates.
132
+
133
+ **Changed defaults:** reads previously allowed 2,000 lines/50 KiB and embedded continuation hints in `content`. Read callers should follow `nextOffset` now. MCP edits previously returned full diffs/patches and used fuzzy fallback automatically; request `output: "full"` or `matchMode: "fuzzy"` explicitly when needed. The low-level `editYjsText` helper retains its legacy fuzzy default; the client/MCP edit path defaults to exact matching.
134
+
135
+ ## Batch replacements for agents
136
+
137
+ Use one `typst_find_and_replace` call for related replacements. For example, expand abbreviated month names throughout all `.typ` files:
138
+
139
+ ```json
140
+ {
141
+ "projectId": "your-project-id",
142
+ "glob": "*.typ",
143
+ "replacements": [
144
+ { "pattern": "\\bSep\\b", "replacement": "September", "regex": true },
145
+ { "pattern": "\\bOct\\b", "replacement": "October", "regex": true }
146
+ ]
147
+ }
148
+ ```
149
+
150
+ Each file is downloaded once, all rules run in order on the previous rule's result, and the file is saved once if its final text changed. All rules are validated before any files are written. File mutations remain serialized with other edits; a project-wide operation is **not atomic across files** if a network or save error occurs.
151
+
152
+ Batch calls default to `output: "summary"`:
153
+
154
+ ```json
155
+ {
156
+ "filesModified": [
157
+ {
158
+ "file": "main.typ",
159
+ "replacementsCount": 3,
160
+ "replacementCounts": [2, 1],
161
+ "firstChangedLine": 4
162
+ }
163
+ ],
164
+ "totalReplacements": 3,
165
+ "dryRun": false
166
+ }
167
+ ```
168
+
169
+ `replacementCounts` follows the input rule order and counts matches applied by each rule in that changed file. Unchanged files, including batches whose rules cancel each other out, are omitted and do not contribute to totals.
170
+
171
+ - Set `dryRun: true` to preview without saving. Add `output: "diff"` to include a single combined display diff per file.
172
+ - Set `output: "full"` to include both the display diff and unified patch.
173
+ - Set `regex`, `ignoreCase`, and `limit` on each batch rule. `limit` is a non-negative integer per rule per file; `0` disables that rule.
174
+ - Use either `replacements` or the existing `pattern`/`replacement` fields. Existing single-replacement calls still default to `output: "full"`; they can request `"summary"` too.
175
+ - Use `filePath` to target one file by path or ID. A missing explicit file returns an error.
176
+
110
177
  ## Development & Verification
111
178
 
179
+ ### From Source (Development)
180
+
181
+ Requires [**Bun**](https://bun.sh) `>= 1.2`:
182
+
183
+ ```bash
184
+ git clone https://github.com/caphefalumi/typst-web-mcp.git
185
+ cd typst-web-mcp
186
+ bun install
187
+ bun run dev
188
+ ```
189
+
190
+ ### Build & Test
191
+
112
192
  ```bash
113
193
  # Compile publishable output to dist/
114
194
  bun run build
@@ -13,9 +13,31 @@ function extractRawSetCookies(headers) {
13
13
  }
14
14
  return [];
15
15
  }
16
+ export function getDefaultSessionFilePath() {
17
+ if (process.env.TYPST_SESSION_FILE) {
18
+ return process.env.TYPST_SESSION_FILE;
19
+ }
20
+ const platform = process.platform;
21
+ if (platform === "win32") {
22
+ const localAppData = process.env.LOCALAPPDATA ||
23
+ (process.env.APPDATA
24
+ ? join(process.env.APPDATA, "..", "Local")
25
+ : join(homedir(), "AppData", "Local"));
26
+ return join(localAppData, "typst-mcp", "session.json");
27
+ }
28
+ if (platform === "darwin") {
29
+ if (process.env.XDG_CACHE_HOME) {
30
+ return join(process.env.XDG_CACHE_HOME, "typst-mcp", "session.json");
31
+ }
32
+ return join(homedir(), "Library", "Caches", "typst-mcp", "session.json");
33
+ }
34
+ // Linux and other POSIX systems (XDG Base Directory specification)
35
+ const cacheDir = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
36
+ return join(cacheDir, "typst-mcp", "session.json");
37
+ }
16
38
  function resolveSessionFilePath(configuredPath) {
17
39
  if (configuredPath === undefined) {
18
- return join(homedir(), ".pi", "agent", "typst-mcp-session.json");
40
+ return getDefaultSessionFilePath();
19
41
  }
20
42
  return configuredPath;
21
43
  }
@@ -92,11 +114,22 @@ export class TypstAuthManager {
92
114
  }
93
115
  }
94
116
  loadStoredSession() {
95
- if (!this.sessionFilePath || !existsSync(this.sessionFilePath)) {
117
+ if (!this.sessionFilePath) {
96
118
  return;
97
119
  }
120
+ let targetPath = this.sessionFilePath;
121
+ if (!existsSync(targetPath)) {
122
+ // Legacy fallback check for seamless migration from ~/.pi/agent/typst-mcp-session.json
123
+ const legacyPath = join(homedir(), ".pi", "agent", "typst-mcp-session.json");
124
+ if (existsSync(legacyPath)) {
125
+ targetPath = legacyPath;
126
+ }
127
+ else {
128
+ return;
129
+ }
130
+ }
98
131
  try {
99
- const raw = JSON.parse(readFileSync(this.sessionFilePath, "utf8"));
132
+ const raw = JSON.parse(readFileSync(targetPath, "utf8"));
100
133
  if (raw && typeof raw.cookies === "object" && raw.cookies) {
101
134
  this.cookies = { ...raw.cookies };
102
135
  this.authMethod = raw.authMethod ?? "cookie";
@@ -7,10 +7,11 @@ import { captureBrowserPreviewScreenshot, captureWebDiagnostics, captureWorkspac
7
7
  import { CREATE_PROJECT_MUTATION, DASHBOARD_CONTENT_QUERY, DELETE_FILES_MUTATION, DELETE_PROJECT_MUTATION, EDIT_PROJECT_MUTATION, PROJECT_FILES_QUERY, UPDATE_FILES_MUTATION, } from "../protocol/graphql.js";
8
8
  import { decodeYjsText, editYjsText, replaceYjsText, } from "../protocol/yjs-doc.js";
9
9
  import { decodeMessage, encodeMessage, MessageKind, } from "../protocol/ws-codec.js";
10
- import { truncateHead, formatSize, } from "../utils/truncate.js";
10
+ import { readTextContent, validateReadOptions, hashText } from "../protocol/read-content.js";
11
+ import { validateEdits } from "../protocol/edit-diff.js";
11
12
  import { detectSupportedImageMimeType } from "../utils/mime.js";
12
13
  import { withFileMutationQueue } from "../utils/file-mutation-queue.js";
13
- import { matchGlob, searchInContent, replaceInContent, } from "../protocol/search-replace.js";
14
+ import { matchGlob, searchInContent, replaceBatchInContent, resolveReplacementRules, } from "../protocol/search-replace.js";
14
15
  export { TypstLoadError, resolveTypstBinary };
15
16
  const URL_ALPHABET = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
16
17
  export function generateClientId(size = 8) {
@@ -220,90 +221,14 @@ export class TypstCloudClient {
220
221
  return new Uint8Array(await response.arrayBuffer());
221
222
  }
222
223
  async readFile(projectId, fileIdOrPath, options) {
224
+ validateReadOptions(options ?? {});
223
225
  const file = await this.resolveFile(projectId, fileIdOrPath);
224
226
  if (file.isMultiplayable) {
225
227
  const yjsBytes = await this.downloadFileYjs(projectId, file.id);
226
- const text = decodeYjsText(yjsBytes);
227
- const allLines = text.split("\n");
228
- const totalLines = allLines.length;
229
- if (options?.offset !== undefined && options.offset > totalLines) {
230
- throw new Error(`Offset ${options.offset} is beyond end of file (${totalLines} lines total)`);
231
- }
232
- const startLine = options?.offset ? Math.max(0, options.offset - 1) : 0;
233
- const startLineDisplay = startLine + 1;
234
- let selectedContent;
235
- let userLimitedLines;
236
- if (options?.limit !== undefined) {
237
- const endLine = Math.min(startLine + options.limit, totalLines);
238
- selectedContent = allLines.slice(startLine, endLine).join("\n");
239
- userLimitedLines = endLine - startLine;
240
- }
241
- else {
242
- selectedContent = allLines.slice(startLine).join("\n");
243
- }
244
- const truncation = truncateHead(selectedContent, {
245
- maxLines: options?.maxLines,
246
- maxBytes: options?.maxBytes,
247
- });
248
- let outputText;
249
- let endLineDisplay = startLineDisplay;
250
- if (truncation.firstLineExceedsLimit) {
251
- const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine] ?? "", "utf-8"));
252
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(truncation.maxBytes)} limit]`;
253
- }
254
- else if (truncation.truncated) {
255
- endLineDisplay = startLineDisplay + truncation.outputLines - 1;
256
- const nextOffset = endLineDisplay + 1;
257
- let body = truncation.content;
258
- if (options?.lineNumbers) {
259
- const lines = body.split("\n");
260
- const width = String(endLineDisplay).length;
261
- body = lines
262
- .map((line, idx) => `${String(startLineDisplay + idx).padStart(width, " ")} | ${line}`)
263
- .join("\n");
264
- }
265
- if (truncation.truncatedBy === "lines") {
266
- outputText = `${body}\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
267
- }
268
- else {
269
- outputText = `${body}\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue.]`;
270
- }
271
- }
272
- else if (userLimitedLines !== undefined &&
273
- startLine + userLimitedLines < totalLines) {
274
- endLineDisplay = startLine + userLimitedLines;
275
- const remaining = totalLines - endLineDisplay;
276
- const nextOffset = endLineDisplay + 1;
277
- let body = truncation.content;
278
- if (options?.lineNumbers) {
279
- const lines = body.split("\n");
280
- const width = String(endLineDisplay).length;
281
- body = lines
282
- .map((line, idx) => `${String(startLineDisplay + idx).padStart(width, " ")} | ${line}`)
283
- .join("\n");
284
- }
285
- outputText = `${body}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
286
- }
287
- else {
288
- endLineDisplay = totalLines;
289
- let body = truncation.content;
290
- if (options?.lineNumbers) {
291
- const lines = body.split("\n");
292
- const width = String(startLineDisplay + lines.length - 1).length;
293
- body = lines
294
- .map((line, idx) => `${String(startLineDisplay + idx).padStart(width, " ")} | ${line}`)
295
- .join("\n");
296
- }
297
- outputText = body;
298
- }
299
228
  return {
300
229
  file,
301
- content: outputText,
302
230
  encoding: "utf-8",
303
- totalLines,
304
- startLine: startLineDisplay,
305
- endLine: endLineDisplay,
306
- truncation: truncation.truncated ? truncation : undefined,
231
+ ...readTextContent(decodeYjsText(yjsBytes), options),
307
232
  };
308
233
  }
309
234
  const blob = await this.downloadFileBlob(projectId, file.id);
@@ -433,15 +358,20 @@ export class TypstCloudClient {
433
358
  files.find((f) => f.name === normalized || f.name === filePathOrId);
434
359
  if (existing) {
435
360
  if (existing.isMultiplayable) {
436
- const remote = await this.downloadFileYjs(projectId, existing.id);
437
- const { encodedUpdate } = replaceYjsText(remote, content);
438
- const { syncChannel } = await this.saveYjsUpdate(projectId, existing.id, encodedUpdate, options?.preferWebSocket ?? true);
439
- return {
440
- file: existing,
441
- created: false,
442
- bytesWritten: Buffer.byteLength(content, "utf8"),
443
- syncChannel,
361
+ const replaceExisting = async () => {
362
+ const remote = await this.downloadFileYjs(projectId, existing.id);
363
+ const { encodedUpdate } = replaceYjsText(remote, content);
364
+ const { syncChannel } = await this.saveYjsUpdate(projectId, existing.id, encodedUpdate, options?.preferWebSocket ?? true);
365
+ return {
366
+ file: existing,
367
+ created: false,
368
+ bytesWritten: Buffer.byteLength(content, "utf8"),
369
+ syncChannel,
370
+ };
444
371
  };
372
+ // Path-based writes must use the same ID lock as surgical edits.
373
+ return normalized === existing.id ? replaceExisting() :
374
+ withFileMutationQueue(`${projectId}:${existing.id}`, replaceExisting);
445
375
  }
446
376
  const rawBytes = new TextEncoder().encode(content);
447
377
  const { response } = await this.restCall("PUT", `files/${projectId}/${existing.id}`, { body: rawBytes });
@@ -469,14 +399,29 @@ export class TypstCloudClient {
469
399
  });
470
400
  }
471
401
  async editFile(projectId, filePathOrId, edits, options) {
402
+ validateEdits(edits);
403
+ if (options?.expectedHash !== undefined &&
404
+ (typeof options.expectedHash !== "string" || !/^[a-f\d]{64}$/i.test(options.expectedHash))) {
405
+ throw new Error("expectedHash must be the SHA-256 contentHash returned by typst_read_file.");
406
+ }
407
+ if (options?.matchMode !== undefined && !["exact", "fuzzy"].includes(options.matchMode)) {
408
+ throw new Error("matchMode must be exact or fuzzy.");
409
+ }
410
+ if (options?.dryRun !== undefined && typeof options.dryRun !== "boolean") {
411
+ throw new Error("dryRun must be a boolean.");
412
+ }
472
413
  const file = await this.resolveFile(projectId, filePathOrId);
473
414
  if (!file.isMultiplayable) {
474
415
  throw new TypstLoadError(["NOT_MULTIPLAYABLE"], `File "${file.name}" is a binary blob and cannot be surgically text-edited`);
475
416
  }
476
417
  return withFileMutationQueue(`${projectId}:${file.id}`, async () => {
477
418
  const remote = await this.downloadFileYjs(projectId, file.id);
478
- const { encodedUpdate, previousText, updatedText, appliedCount, diff, patch, firstChangedLine, } = editYjsText(remote, edits, file.name);
479
- const { syncChannel } = await this.saveYjsUpdate(projectId, file.id, encodedUpdate, options?.preferWebSocket ?? true);
419
+ const currentHash = hashText(decodeYjsText(remote));
420
+ if (options?.expectedHash !== undefined && currentHash !== options.expectedHash.toLowerCase()) {
421
+ throw new TypstLoadError(["STALE_CONTENT"], "File changed since read. Read the affected range again and retry with its contentHash.");
422
+ }
423
+ const { encodedUpdate, previousText, updatedText, appliedCount, diff, patch, firstChangedLine, usedFuzzyMatch, } = editYjsText(remote, edits, file.name, options?.matchMode ?? "exact");
424
+ const { syncChannel } = options?.dryRun ? { syncChannel: "none" } : await this.saveYjsUpdate(projectId, file.id, encodedUpdate, options?.preferWebSocket ?? true);
480
425
  return {
481
426
  file,
482
427
  appliedCount,
@@ -486,6 +431,9 @@ export class TypstCloudClient {
486
431
  patch,
487
432
  firstChangedLine,
488
433
  syncChannel,
434
+ contentHash: options?.dryRun ? currentHash : hashText(updatedText),
435
+ dryRun: Boolean(options?.dryRun),
436
+ usedFuzzyMatch,
489
437
  };
490
438
  });
491
439
  }
@@ -749,7 +697,13 @@ export class TypstCloudClient {
749
697
  };
750
698
  }
751
699
  async findAndReplace(projectId, options) {
700
+ const rules = resolveReplacementRules(options);
701
+ const output = options.output ?? (options.replacements ? "summary" : "full");
752
702
  const files = await this.listFiles(projectId);
703
+ if (options.filePath && !files.some(file => file.isMultiplayable &&
704
+ (file.name === options.filePath.replace(/^\/+/, "") || file.id === options.filePath))) {
705
+ throw new Error(`Text file not found: ${options.filePath}`);
706
+ }
753
707
  const filesModified = [];
754
708
  let totalReplacements = 0;
755
709
  for (const file of files) {
@@ -767,14 +721,8 @@ export class TypstCloudClient {
767
721
  await withFileMutationQueue(`${projectId}:${file.id}`, async () => {
768
722
  const remote = await this.downloadFileYjs(projectId, file.id);
769
723
  const text = decodeYjsText(remote);
770
- const repResult = replaceInContent(file.name, text, {
771
- pattern: options.pattern,
772
- replacement: options.replacement,
773
- regex: options.regex,
774
- ignoreCase: options.ignoreCase,
775
- limit: options.limit,
776
- });
777
- if (repResult.replacementCount > 0) {
724
+ const repResult = replaceBatchInContent(file.name, text, rules, output);
725
+ if (repResult.updatedText !== text) {
778
726
  if (!options.dryRun) {
779
727
  const { encodedUpdate } = replaceYjsText(remote, repResult.updatedText);
780
728
  await this.saveYjsUpdate(projectId, file.id, encodedUpdate, options.preferWebSocket ?? true);
@@ -782,8 +730,9 @@ export class TypstCloudClient {
782
730
  filesModified.push({
783
731
  file: file.name,
784
732
  replacementsCount: repResult.replacementCount,
785
- diff: repResult.diff,
786
- patch: repResult.patch,
733
+ ...(repResult.diff !== undefined ? { diff: repResult.diff } : {}),
734
+ ...(repResult.patch !== undefined ? { patch: repResult.patch } : {}),
735
+ ...(options.replacements ? { replacementCounts: repResult.replacementCounts } : {}),
787
736
  firstChangedLine: repResult.firstChangedLine,
788
737
  });
789
738
  totalReplacements += repResult.replacementCount;
package/dist/index.js CHANGED
@@ -8,6 +8,8 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
8
8
  import { TypstCloudClient, } from "./client/typst-client.js";
9
9
  import { formatUnknownError } from "./client/errors.js";
10
10
  import { TYPST_MCP_TOOLS } from "./tools/definitions.js";
11
+ import { validateEdits } from "./protocol/edit-diff.js";
12
+ import { truncateHead } from "./utils/truncate.js";
11
13
  export { TYPST_MCP_TOOLS };
12
14
  function resolvePreferWebSocket(value) {
13
15
  if (typeof value === "boolean") {
@@ -22,32 +24,25 @@ function resolveOptionalNumber(value) {
22
24
  return undefined;
23
25
  }
24
26
  export function prepareEditArguments(input) {
25
- if (!input)
27
+ if (input === undefined)
26
28
  return [];
27
- if (Array.isArray(input)) {
28
- return input.filter((e) => e && typeof e.oldText === "string" && typeof e.newText === "string");
29
- }
30
29
  if (typeof input === "string") {
31
30
  try {
32
- const parsed = JSON.parse(input);
33
- if (Array.isArray(parsed)) {
34
- return parsed.filter((e) => e && typeof e.oldText === "string" && typeof e.newText === "string");
35
- }
36
- if (parsed &&
37
- typeof parsed.oldText === "string" &&
38
- typeof parsed.newText === "string") {
39
- return [parsed];
40
- }
31
+ input = JSON.parse(input);
41
32
  }
42
- catch { }
43
- }
44
- if (typeof input === "object" && input !== null) {
45
- const obj = input;
46
- if (typeof obj.oldText === "string" && typeof obj.newText === "string") {
47
- return [{ oldText: obj.oldText, newText: obj.newText }];
33
+ catch {
34
+ throw new Error("edits must be an array of {oldText, newText}; invalid JSON.");
48
35
  }
49
36
  }
50
- return [];
37
+ const edits = (Array.isArray(input) ? input : [input]);
38
+ validateEdits(edits);
39
+ return edits.map(({ oldText, newText }) => ({ oldText, newText }));
40
+ }
41
+ function requiredText(args, key) {
42
+ if (typeof args[key] !== "string" || !args[key].length) {
43
+ throw new Error(`${key} must be a non-empty string.`);
44
+ }
45
+ return args[key];
51
46
  }
52
47
  export async function dispatchTypstTool(client, name, args = {}) {
53
48
  switch (name) {
@@ -78,28 +73,71 @@ export async function dispatchTypstTool(client, name, args = {}) {
78
73
  }
79
74
  case "typst_list_files":
80
75
  return await client.listFiles(String(args.projectId));
81
- case "typst_read_file":
82
- return await client.readFile(String(args.projectId), String(args.filePath), {
83
- offset: resolveOptionalNumber(args.offset),
84
- limit: resolveOptionalNumber(args.limit),
85
- lineNumbers: typeof args.lineNumbers === "boolean"
86
- ? args.lineNumbers
87
- : undefined,
76
+ case "typst_read_file": {
77
+ if (args.output !== undefined && args.output !== "compact" && args.output !== "full") {
78
+ throw new Error("output must be compact or full.");
79
+ }
80
+ const result = await client.readFile(requiredText(args, "projectId"), requiredText(args, "filePath"), {
81
+ offset: args.offset,
82
+ limit: args.limit,
83
+ maxBytes: args.maxBytes,
84
+ lineNumbers: args.lineNumbers,
88
85
  });
86
+ return args.output === "full" ? result : {
87
+ ...result, file: { id: result.file.id, name: result.file.name },
88
+ };
89
+ }
89
90
  case "typst_write_file":
90
91
  return await client.writeFile(String(args.projectId), String(args.filePath), String(args.content), {
91
92
  preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
92
93
  });
93
94
  case "typst_edit_file": {
94
- let edits = prepareEditArguments(args.edits);
95
- if (edits.length === 0 &&
96
- typeof args.oldText === "string" &&
97
- typeof args.newText === "string") {
98
- edits = [{ oldText: args.oldText, newText: args.newText }];
95
+ const hasShorthand = args.oldText !== undefined || args.newText !== undefined;
96
+ if (args.edits !== undefined && hasShorthand) {
97
+ throw new Error("Use either edits or oldText/newText, not both.");
99
98
  }
100
- return await client.editFile(String(args.projectId), String(args.filePath), edits, {
99
+ const edits = prepareEditArguments(args.edits !== undefined ? args.edits :
100
+ hasShorthand ? { oldText: args.oldText, newText: args.newText } : undefined);
101
+ const output = args.output ?? (args.dryRun === true ? "diff" : "summary");
102
+ if (!["summary", "diff", "full"].includes(output)) {
103
+ throw new Error("output must be summary, diff, or full.");
104
+ }
105
+ const maxDiffBytes = args.maxDiffBytes ?? 8192;
106
+ if (typeof maxDiffBytes !== "number" || !Number.isSafeInteger(maxDiffBytes) ||
107
+ maxDiffBytes < 1 || maxDiffBytes > 1048576) {
108
+ throw new Error("maxDiffBytes must be an integer between 1 and 1048576.");
109
+ }
110
+ if (args.preferWebSocket !== undefined && typeof args.preferWebSocket !== "boolean") {
111
+ throw new Error("preferWebSocket must be a boolean.");
112
+ }
113
+ const result = await client.editFile(requiredText(args, "projectId"), requiredText(args, "filePath"), edits, {
101
114
  preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
115
+ expectedHash: args.expectedHash,
116
+ matchMode: args.matchMode,
117
+ dryRun: args.dryRun,
102
118
  });
119
+ const compact = {
120
+ file: { id: result.file.id, name: result.file.name },
121
+ appliedCount: result.appliedCount,
122
+ contentHash: result.contentHash,
123
+ firstChangedLine: result.firstChangedLine,
124
+ ...(result.dryRun ? { dryRun: true } : {}),
125
+ ...(result.usedFuzzyMatch ? { usedFuzzyMatch: true } : {}),
126
+ };
127
+ if (output === "summary")
128
+ return compact;
129
+ const diff = truncateHead(result.diff, { maxLines: 100, maxBytes: maxDiffBytes });
130
+ const diffFields = {
131
+ diff: diff.content,
132
+ ...(diff.truncated ? { diffTruncated: true, hint: "Read the affected range to inspect all changes, or increase maxDiffBytes (100 diff lines maximum)." } : {}),
133
+ };
134
+ if (output === "diff")
135
+ return { ...compact, ...diffFields };
136
+ const patch = truncateHead(result.patch, { maxLines: 100, maxBytes: maxDiffBytes });
137
+ return {
138
+ ...result, ...diffFields, patch: patch.content,
139
+ ...(patch.truncated ? { patchTruncated: true } : {}),
140
+ };
103
141
  }
104
142
  case "typst_upload_asset": {
105
143
  let bytes;
@@ -149,13 +187,15 @@ export async function dispatchTypstTool(client, name, args = {}) {
149
187
  });
150
188
  case "typst_find_and_replace":
151
189
  return await client.findAndReplace(String(args.projectId), {
152
- pattern: String(args.pattern),
153
- replacement: String(args.replacement),
190
+ pattern: args.pattern,
191
+ replacement: args.replacement,
192
+ replacements: args.replacements,
193
+ output: args.output,
154
194
  filePath: args.filePath,
155
195
  glob: args.glob,
156
- regex: Boolean(args.regex),
157
- ignoreCase: Boolean(args.ignoreCase),
158
- limit: resolveOptionalNumber(args.limit),
196
+ regex: args.regex,
197
+ ignoreCase: args.ignoreCase,
198
+ limit: args.limit,
159
199
  dryRun: Boolean(args.dryRun),
160
200
  preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
161
201
  });
@@ -167,7 +207,7 @@ export function createTypstMcpServer(options = {}) {
167
207
  const client = new TypstCloudClient(options);
168
208
  const server = new Server({
169
209
  name: "typst-mcp",
170
- version: "1.1.0",
210
+ version: "1.3.0",
171
211
  }, {
172
212
  capabilities: {
173
213
  tools: {},
@@ -210,7 +250,7 @@ export function createTypstMcpServer(options = {}) {
210
250
  content: [
211
251
  {
212
252
  type: "text",
213
- text: JSON.stringify(result, null, 2),
253
+ text: JSON.stringify(result, null, name === "typst_read_file" || name === "typst_edit_file" ? undefined : 2),
214
254
  },
215
255
  ],
216
256
  };
@@ -133,10 +133,13 @@ export function fuzzyFindText(content, oldText) {
133
133
  contentForReplacement: content,
134
134
  };
135
135
  }
136
- const fuzzyContent = normalizeForFuzzyMatch(content);
136
+ const { normalized: fuzzyContent, starts, ends } = fuzzyTextWithOffsets(content);
137
137
  const fuzzyOldText = normalizeForFuzzyMatch(oldText);
138
- const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
139
- if (fuzzyIndex === -1) {
138
+ const fuzzyIndex = fuzzyOldText.length ? fuzzyContent.indexOf(fuzzyOldText) : -1;
139
+ // Do not replace part of an expanded Unicode grapheme (e.g. one f in ffi).
140
+ const last = fuzzyIndex + fuzzyOldText.length - 1;
141
+ if (fuzzyIndex === -1 || starts[fuzzyIndex] === starts[fuzzyIndex - 1] ||
142
+ ends[last] === ends[last + 1]) {
140
143
  return {
141
144
  found: false,
142
145
  index: -1,
@@ -147,20 +150,69 @@ export function fuzzyFindText(content, oldText) {
147
150
  }
148
151
  return {
149
152
  found: true,
150
- index: fuzzyIndex,
151
- matchLength: fuzzyOldText.length,
153
+ index: starts[fuzzyIndex],
154
+ matchLength: ends[last] - starts[fuzzyIndex],
152
155
  usedFuzzyMatch: true,
153
- contentForReplacement: fuzzyContent,
156
+ contentForReplacement: content,
154
157
  };
155
158
  }
159
+ /** Keep original offsets so fallback matching cannot normalize unrelated text. */
160
+ function fuzzyTextWithOffsets(content) {
161
+ let normalized = "";
162
+ const starts = [];
163
+ const ends = [];
164
+ const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
165
+ for (const { segment, index } of segmenter.segment(content)) {
166
+ // Append a sentinel so trailing spaces are retained until the line-level trim.
167
+ const value = normalizeForFuzzyMatch(segment + "\u0000").slice(0, -1);
168
+ normalized += value;
169
+ for (let i = 0; i < value.length; i++) {
170
+ starts.push(index);
171
+ ends.push(index + segment.length);
172
+ }
173
+ }
174
+ let offset = 0;
175
+ const keptStarts = [];
176
+ const keptEnds = [];
177
+ const trimmedLines = normalized.split("\n").map(line => {
178
+ const trimmed = line.trimEnd();
179
+ for (let i = 0; i < trimmed.length; i++) {
180
+ keptStarts.push(starts[offset + i]);
181
+ keptEnds.push(ends[offset + i]);
182
+ }
183
+ offset += line.length;
184
+ if (offset < normalized.length) {
185
+ keptStarts.push(starts[offset]);
186
+ keptEnds.push(ends[offset]);
187
+ offset++;
188
+ }
189
+ return trimmed;
190
+ });
191
+ return { normalized: trimmedLines.join("\n"), starts: keptStarts, ends: keptEnds };
192
+ }
156
193
  export function countOccurrences(content, oldText) {
157
- const fuzzyContent = normalizeForFuzzyMatch(content);
158
- const fuzzyOldText = normalizeForFuzzyMatch(oldText);
159
- if (fuzzyOldText.length === 0)
194
+ if (!oldText.length)
160
195
  return 0;
161
- return fuzzyContent.split(fuzzyOldText).length - 1;
196
+ let count = 0;
197
+ let offset = content.indexOf(oldText);
198
+ while (offset !== -1) {
199
+ count++;
200
+ offset = content.indexOf(oldText, offset + 1);
201
+ }
202
+ return count;
203
+ }
204
+ export function validateEdits(edits) {
205
+ if (!Array.isArray(edits) || edits.length === 0) {
206
+ throw new Error("At least one edit operation is required");
207
+ }
208
+ for (const [index, edit] of edits.entries()) {
209
+ if (!edit || typeof edit.oldText !== "string" || typeof edit.newText !== "string" || !edit.oldText.length) {
210
+ throw new Error(`Edit ${index + 1} requires a non-empty oldText and a string newText.`);
211
+ }
212
+ }
162
213
  }
163
- export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
214
+ export function applyEditsToNormalizedContent(normalizedContent, edits, path, matchMode = "fuzzy") {
215
+ validateEdits(edits);
164
216
  const normalizedEdits = edits.map((e) => ({
165
217
  oldText: normalizeToLF(e.oldText),
166
218
  newText: normalizeToLF(e.newText),
@@ -170,19 +222,20 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
170
222
  throw new Error(`oldText cannot be empty for edit in ${path}`);
171
223
  }
172
224
  }
173
- const initialMatches = normalizedEdits.map((e) => fuzzyFindText(normalizedContent, e.oldText));
174
- const usedFuzzyMatch = initialMatches.some((m) => m.usedFuzzyMatch);
175
- const replacementBaseContent = usedFuzzyMatch
176
- ? normalizeForFuzzyMatch(normalizedContent)
177
- : normalizedContent;
225
+ let usedFuzzyMatch = false;
178
226
  const matchedEdits = [];
179
227
  for (let i = 0; i < normalizedEdits.length; i++) {
180
228
  const edit = normalizedEdits[i];
181
- const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
229
+ const exactIndex = normalizedContent.indexOf(edit.oldText);
230
+ const matchResult = exactIndex !== -1 ? {
231
+ found: true, index: exactIndex, matchLength: edit.oldText.length, usedFuzzyMatch: false,
232
+ } : matchMode === "fuzzy" ? fuzzyFindText(normalizedContent, edit.oldText) : { found: false };
182
233
  if (!matchResult.found) {
183
234
  throw new Error(`Could not find match for oldText in ${path}: ${JSON.stringify(edit.oldText.slice(0, 80))}`);
184
235
  }
185
- const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
236
+ const fuzzy = Boolean(matchResult.usedFuzzyMatch);
237
+ usedFuzzyMatch ||= fuzzy;
238
+ const occurrences = countOccurrences(fuzzy ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent, fuzzy ? normalizeForFuzzyMatch(edit.oldText) : edit.oldText);
186
239
  if (occurrences > 1) {
187
240
  throw new Error(`Found ${occurrences} occurrences of oldText in ${path} (must be unique): ${JSON.stringify(edit.oldText.slice(0, 80))}`);
188
241
  }
@@ -202,13 +255,11 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
202
255
  }
203
256
  }
204
257
  const baseContent = normalizedContent;
205
- const newContent = usedFuzzyMatch
206
- ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
207
- : applyReplacements(replacementBaseContent, matchedEdits);
258
+ const newContent = applyReplacements(normalizedContent, matchedEdits);
208
259
  if (baseContent === newContent) {
209
260
  throw new Error(`No changes made to ${path}: newText equals oldText.`);
210
261
  }
211
- return { baseContent, newContent };
262
+ return { baseContent, newContent, matchedEdits, usedFuzzyMatch };
212
263
  }
213
264
  /**
214
265
  * Generate a standard unified patch.
@@ -239,7 +290,7 @@ export function generateDiffString(oldContent, newContent, contextLines = 4) {
239
290
  raw.pop();
240
291
  }
241
292
  if (part.added || part.removed) {
242
- if (firstChangedLine === undefined && part.added) {
293
+ if (firstChangedLine === undefined) {
243
294
  firstChangedLine = newLineNum;
244
295
  }
245
296
  for (const line of raw) {
@@ -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
  {
@@ -422,7 +458,7 @@ export const TYPST_MCP_TOOLS = [
422
458
  },
423
459
  {
424
460
  name: "typst_find_and_replace",
425
- 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.",
426
462
  inputSchema: {
427
463
  type: "object",
428
464
  properties: {
@@ -438,6 +474,27 @@ export const TYPST_MCP_TOOLS = [
438
474
  type: "string",
439
475
  description: "Replacement text (supports $1, $2 capture groups if regex is true)",
440
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
+ },
441
498
  filePath: {
442
499
  type: "string",
443
500
  description: "Optional file path/name (e.g. 'main.typ'). If omitted, searches and replaces across all text files in the project.",
@@ -467,7 +524,11 @@ export const TYPST_MCP_TOOLS = [
467
524
  description: "Push real-time TextUpdate over /editing/v3 WebSocket before REST persistence (default: true)",
468
525
  },
469
526
  },
470
- 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
+ ],
471
532
  },
472
533
  },
473
534
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typst-mcp",
3
- "version": "1.2.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",