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.
@@ -3,14 +3,15 @@ import { dirname, join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import { TypstAuthManager } from "./auth.js";
5
5
  import { TypstLoadError, formatUnknownError } from "./errors.js";
6
- import { captureBrowserPreviewScreenshot, captureWorkspacePageScreenshots, compileWorkspaceDiagnostics, compileWorkspacePdf, parseShortDiagnostics, resolveTypstBinary, } from "./compiler.js";
7
- import { CREATE_PROJECT_MUTATION, DASHBOARD_CONTENT_QUERY, DELETE_FILES_MUTATION, DELETE_PROJECT_MUTATION, PROJECT_FILES_QUERY, UPDATE_FILES_MUTATION, } from "../protocol/graphql.js";
6
+ import { captureBrowserPreviewScreenshot, captureWebDiagnostics, captureWorkspacePageScreenshots, compileWorkspaceDiagnostics, compileWorkspacePdf, hasTypstBinary, parseShortDiagnostics, resolveTypstBinary, } from "./compiler.js";
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
  }
@@ -571,18 +519,92 @@ export class TypstCloudClient {
571
519
  static parseShortDiagnostics(stderrText) {
572
520
  return parseShortDiagnostics(stderrText);
573
521
  }
574
- async getDiagnostics(projectId, mainFile) {
575
- const { workspaceDir, mainFileName } = await this.materializeProjectWorkspace(projectId, mainFile);
522
+ async updateProject(projectId, updates) {
523
+ const res = await this.graphql(EDIT_PROJECT_MUTATION, {
524
+ projectId,
525
+ name: updates.name,
526
+ previewed: updates.previewed,
527
+ pinnedVersion: updates.pinnedVersion,
528
+ });
529
+ return res.updateProject;
530
+ }
531
+ async getWebDiagnostics(projectId, mainFile) {
532
+ const project = await this.getProject(projectId);
533
+ let effectiveMainFile = mainFile;
534
+ if (!effectiveMainFile) {
535
+ const previewedFile = project.files.find((f) => f.id === project.previewed);
536
+ effectiveMainFile = previewedFile?.name ?? "main.typ";
537
+ }
538
+ else {
539
+ const requestedFile = project.files.find((f) => f.name === mainFile || f.id === mainFile);
540
+ if (requestedFile && requestedFile.id !== project.previewed) {
541
+ try {
542
+ await this.updateProject(projectId, { previewed: requestedFile.id });
543
+ }
544
+ catch {
545
+ // If permission denied or mutation rejected, proceed anyway
546
+ }
547
+ }
548
+ }
549
+ const fileContents = {};
550
+ for (const f of project.files) {
551
+ if (f.isMultiplayable) {
552
+ try {
553
+ const res = await this.readFile(projectId, f.name);
554
+ if (res.encoding === "utf-8" && typeof res.content === "string") {
555
+ fileContents[f.name] = res.content;
556
+ }
557
+ }
558
+ catch {
559
+ // Ignore read failures for auxiliary files
560
+ }
561
+ }
562
+ }
563
+ return captureWebDiagnostics({
564
+ appUrl: this.auth.appUrl,
565
+ projectId,
566
+ cookieHeader: this.auth.getCookieHeader(),
567
+ mainFile: effectiveMainFile,
568
+ fileContents,
569
+ });
570
+ }
571
+ async getDiagnostics(projectId, options) {
572
+ const opts = typeof options === "string" ? { mainFile: options } : options;
573
+ const mode = opts?.mode ?? "auto";
574
+ const requestedMainFile = opts?.mainFile;
575
+ if (mode === "web") {
576
+ return await this.getWebDiagnostics(projectId, requestedMainFile);
577
+ }
578
+ const typstAvailable = hasTypstBinary(this.typstBin);
579
+ if (!typstAvailable) {
580
+ if (mode === "cli") {
581
+ throw new TypstLoadError(["TYPST_BINARY_NOT_FOUND"], `Typst compiler binary '${this.typstBin}' was not found. Please install the Typst compiler (e.g. 'winget install Typst.Typst' / 'brew install typst') or use mode: 'web' for zero-binary cloud diagnostics.`);
582
+ }
583
+ return await this.getWebDiagnostics(projectId, requestedMainFile);
584
+ }
576
585
  try {
577
- return compileWorkspaceDiagnostics({
578
- typstBin: this.typstBin,
579
- projectId,
580
- workspaceDir,
581
- mainFileName,
582
- });
586
+ const { workspaceDir, mainFileName } = await this.materializeProjectWorkspace(projectId, requestedMainFile);
587
+ try {
588
+ const res = compileWorkspaceDiagnostics({
589
+ typstBin: this.typstBin,
590
+ projectId,
591
+ workspaceDir,
592
+ mainFileName,
593
+ });
594
+ return {
595
+ ...res,
596
+ mode: "cli",
597
+ };
598
+ }
599
+ finally {
600
+ rmSync(workspaceDir, { recursive: true, force: true });
601
+ }
583
602
  }
584
- finally {
585
- rmSync(workspaceDir, { recursive: true, force: true });
603
+ catch (err) {
604
+ if (mode === "auto") {
605
+ return await this.getWebDiagnostics(projectId, requestedMainFile);
606
+ }
607
+ throw err;
586
608
  }
587
609
  }
588
610
  async exportPdf(projectId, options) {
@@ -675,7 +697,13 @@ export class TypstCloudClient {
675
697
  };
676
698
  }
677
699
  async findAndReplace(projectId, options) {
700
+ const rules = resolveReplacementRules(options);
701
+ const output = options.output ?? (options.replacements ? "summary" : "full");
678
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
+ }
679
707
  const filesModified = [];
680
708
  let totalReplacements = 0;
681
709
  for (const file of files) {
@@ -693,14 +721,8 @@ export class TypstCloudClient {
693
721
  await withFileMutationQueue(`${projectId}:${file.id}`, async () => {
694
722
  const remote = await this.downloadFileYjs(projectId, file.id);
695
723
  const text = decodeYjsText(remote);
696
- const repResult = replaceInContent(file.name, text, {
697
- pattern: options.pattern,
698
- replacement: options.replacement,
699
- regex: options.regex,
700
- ignoreCase: options.ignoreCase,
701
- limit: options.limit,
702
- });
703
- if (repResult.replacementCount > 0) {
724
+ const repResult = replaceBatchInContent(file.name, text, rules, output);
725
+ if (repResult.updatedText !== text) {
704
726
  if (!options.dryRun) {
705
727
  const { encodedUpdate } = replaceYjsText(remote, repResult.updatedText);
706
728
  await this.saveYjsUpdate(projectId, file.id, encodedUpdate, options.preferWebSocket ?? true);
@@ -708,8 +730,9 @@ export class TypstCloudClient {
708
730
  filesModified.push({
709
731
  file: file.name,
710
732
  replacementsCount: repResult.replacementCount,
711
- diff: repResult.diff,
712
- patch: repResult.patch,
733
+ ...(repResult.diff !== undefined ? { diff: repResult.diff } : {}),
734
+ ...(repResult.patch !== undefined ? { patch: repResult.patch } : {}),
735
+ ...(options.replacements ? { replacementCounts: repResult.replacementCounts } : {}),
713
736
  firstChangedLine: repResult.firstChangedLine,
714
737
  });
715
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.");
98
+ }
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.");
99
104
  }
100
- return await client.editFile(String(args.projectId), String(args.filePath), edits, {
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;
@@ -119,7 +157,10 @@ export async function dispatchTypstTool(client, name, args = {}) {
119
157
  case "typst_rename_file":
120
158
  return await client.renameFile(String(args.projectId), String(args.filePath), String(args.newName));
121
159
  case "typst_get_diagnostics":
122
- return await client.getDiagnostics(String(args.projectId), args.mainFile);
160
+ return await client.getDiagnostics(String(args.projectId), {
161
+ mainFile: args.mainFile,
162
+ mode: args.mode,
163
+ });
123
164
  case "typst_export_pdf":
124
165
  return await client.exportPdf(String(args.projectId), {
125
166
  mainFile: args.mainFile,
@@ -146,13 +187,15 @@ export async function dispatchTypstTool(client, name, args = {}) {
146
187
  });
147
188
  case "typst_find_and_replace":
148
189
  return await client.findAndReplace(String(args.projectId), {
149
- pattern: String(args.pattern),
150
- replacement: String(args.replacement),
190
+ pattern: args.pattern,
191
+ replacement: args.replacement,
192
+ replacements: args.replacements,
193
+ output: args.output,
151
194
  filePath: args.filePath,
152
195
  glob: args.glob,
153
- regex: Boolean(args.regex),
154
- ignoreCase: Boolean(args.ignoreCase),
155
- limit: resolveOptionalNumber(args.limit),
196
+ regex: args.regex,
197
+ ignoreCase: args.ignoreCase,
198
+ limit: args.limit,
156
199
  dryRun: Boolean(args.dryRun),
157
200
  preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
158
201
  });
@@ -164,7 +207,7 @@ export function createTypstMcpServer(options = {}) {
164
207
  const client = new TypstCloudClient(options);
165
208
  const server = new Server({
166
209
  name: "typst-mcp",
167
- version: "1.1.0",
210
+ version: "1.3.0",
168
211
  }, {
169
212
  capabilities: {
170
213
  tools: {},
@@ -207,7 +250,7 @@ export function createTypstMcpServer(options = {}) {
207
250
  content: [
208
251
  {
209
252
  type: "text",
210
- text: JSON.stringify(result, null, 2),
253
+ text: JSON.stringify(result, null, name === "typst_read_file" || name === "typst_edit_file" ? undefined : 2),
211
254
  },
212
255
  ],
213
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) {