typst-mcp 1.0.2 → 1.2.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,10 +3,14 @@ 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";
11
+ import { detectSupportedImageMimeType } from "../utils/mime.js";
12
+ import { withFileMutationQueue } from "../utils/file-mutation-queue.js";
13
+ import { matchGlob, searchInContent, replaceInContent, } from "../protocol/search-replace.js";
10
14
  export { TypstLoadError, resolveTypstBinary };
11
15
  const URL_ALPHABET = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
12
16
  export function generateClientId(size = 8) {
@@ -215,22 +219,102 @@ export class TypstCloudClient {
215
219
  const { response } = await this.restCall("GET", `files/${projectId}/${fileId}`);
216
220
  return new Uint8Array(await response.arrayBuffer());
217
221
  }
218
- async readFile(projectId, fileIdOrPath) {
222
+ async readFile(projectId, fileIdOrPath, options) {
219
223
  const file = await this.resolveFile(projectId, fileIdOrPath);
220
224
  if (file.isMultiplayable) {
221
225
  const yjsBytes = await this.downloadFileYjs(projectId, file.id);
222
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
+ }
223
299
  return {
224
300
  file,
225
- content: text,
301
+ content: outputText,
226
302
  encoding: "utf-8",
303
+ totalLines,
304
+ startLine: startLineDisplay,
305
+ endLine: endLineDisplay,
306
+ truncation: truncation.truncated ? truncation : undefined,
227
307
  };
228
308
  }
229
309
  const blob = await this.downloadFileBlob(projectId, file.id);
310
+ const mimeType = detectSupportedImageMimeType(file.name);
311
+ const isImage = Boolean(mimeType && mimeType.startsWith("image/"));
230
312
  return {
231
313
  file,
232
314
  content: Buffer.from(blob).toString("base64"),
233
315
  encoding: "base64",
316
+ mimeType,
317
+ isImage,
234
318
  };
235
319
  }
236
320
  async pushYjsUpdateViaWebSocket(projectId, fileId, encodedUpdate, timeoutMs = 5000) {
@@ -342,61 +426,68 @@ export class TypstCloudClient {
342
426
  return { syncChannel };
343
427
  }
344
428
  async writeFile(projectId, filePathOrId, content, options) {
345
- const files = await this.listFiles(projectId);
346
429
  const normalized = filePathOrId.replace(/^\/+/, "");
347
- const existing = files.find((f) => f.id === filePathOrId) ??
348
- files.find((f) => f.name === normalized || f.name === filePathOrId);
349
- if (existing) {
350
- if (existing.isMultiplayable) {
351
- const remote = await this.downloadFileYjs(projectId, existing.id);
352
- const { encodedUpdate } = replaceYjsText(remote, content);
353
- const { syncChannel } = await this.saveYjsUpdate(projectId, existing.id, encodedUpdate, options?.preferWebSocket ?? true);
430
+ return withFileMutationQueue(`${projectId}:${normalized}`, async () => {
431
+ const files = await this.listFiles(projectId);
432
+ const existing = files.find((f) => f.id === filePathOrId) ??
433
+ files.find((f) => f.name === normalized || f.name === filePathOrId);
434
+ if (existing) {
435
+ 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,
444
+ };
445
+ }
446
+ const rawBytes = new TextEncoder().encode(content);
447
+ const { response } = await this.restCall("PUT", `files/${projectId}/${existing.id}`, { body: rawBytes });
448
+ const updatedMeta = (await response.json());
354
449
  return {
355
- file: existing,
450
+ file: updatedMeta,
356
451
  created: false,
357
- bytesWritten: Buffer.byteLength(content, "utf8"),
358
- syncChannel,
452
+ bytesWritten: rawBytes.byteLength,
453
+ syncChannel: "rest",
359
454
  };
360
455
  }
361
456
  const rawBytes = new TextEncoder().encode(content);
362
- const { response } = await this.restCall("PUT", `files/${projectId}/${existing.id}`, { body: rawBytes });
363
- const updatedMeta = (await response.json());
457
+ const { response } = await this.restCall("POST", `files/${projectId}`, {
458
+ queryParams: { name: normalized, convert: "false" },
459
+ body: rawBytes,
460
+ });
461
+ const body = (await response.json());
462
+ const createdMeta = unwrapSingleOrFirstFile(body);
364
463
  return {
365
- file: updatedMeta,
366
- created: false,
464
+ file: createdMeta,
465
+ created: true,
367
466
  bytesWritten: rawBytes.byteLength,
368
467
  syncChannel: "rest",
369
468
  };
370
- }
371
- const rawBytes = new TextEncoder().encode(content);
372
- const { response } = await this.restCall("POST", `files/${projectId}`, {
373
- queryParams: { name: normalized, convert: "false" },
374
- body: rawBytes,
375
469
  });
376
- const body = (await response.json());
377
- const createdMeta = unwrapSingleOrFirstFile(body);
378
- return {
379
- file: createdMeta,
380
- created: true,
381
- bytesWritten: rawBytes.byteLength,
382
- syncChannel: "rest",
383
- };
384
470
  }
385
471
  async editFile(projectId, filePathOrId, edits, options) {
386
472
  const file = await this.resolveFile(projectId, filePathOrId);
387
473
  if (!file.isMultiplayable) {
388
474
  throw new TypstLoadError(["NOT_MULTIPLAYABLE"], `File "${file.name}" is a binary blob and cannot be surgically text-edited`);
389
475
  }
390
- const remote = await this.downloadFileYjs(projectId, file.id);
391
- const { encodedUpdate, previousText, updatedText, appliedCount } = editYjsText(remote, edits);
392
- const { syncChannel } = await this.saveYjsUpdate(projectId, file.id, encodedUpdate, options?.preferWebSocket ?? true);
393
- return {
394
- file,
395
- appliedCount,
396
- previousLength: previousText.length,
397
- updatedLength: updatedText.length,
398
- syncChannel,
399
- };
476
+ return withFileMutationQueue(`${projectId}:${file.id}`, async () => {
477
+ 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);
480
+ return {
481
+ file,
482
+ appliedCount,
483
+ previousLength: previousText.length,
484
+ updatedLength: updatedText.length,
485
+ diff,
486
+ patch,
487
+ firstChangedLine,
488
+ syncChannel,
489
+ };
490
+ });
400
491
  }
401
492
  async uploadAsset(projectId, fileName, data, options) {
402
493
  const normalized = fileName.replace(/^\/+/, "");
@@ -480,18 +571,92 @@ export class TypstCloudClient {
480
571
  static parseShortDiagnostics(stderrText) {
481
572
  return parseShortDiagnostics(stderrText);
482
573
  }
483
- async getDiagnostics(projectId, mainFile) {
484
- const { workspaceDir, mainFileName } = await this.materializeProjectWorkspace(projectId, mainFile);
574
+ async updateProject(projectId, updates) {
575
+ const res = await this.graphql(EDIT_PROJECT_MUTATION, {
576
+ projectId,
577
+ name: updates.name,
578
+ previewed: updates.previewed,
579
+ pinnedVersion: updates.pinnedVersion,
580
+ });
581
+ return res.updateProject;
582
+ }
583
+ async getWebDiagnostics(projectId, mainFile) {
584
+ const project = await this.getProject(projectId);
585
+ let effectiveMainFile = mainFile;
586
+ if (!effectiveMainFile) {
587
+ const previewedFile = project.files.find((f) => f.id === project.previewed);
588
+ effectiveMainFile = previewedFile?.name ?? "main.typ";
589
+ }
590
+ else {
591
+ const requestedFile = project.files.find((f) => f.name === mainFile || f.id === mainFile);
592
+ if (requestedFile && requestedFile.id !== project.previewed) {
593
+ try {
594
+ await this.updateProject(projectId, { previewed: requestedFile.id });
595
+ }
596
+ catch {
597
+ // If permission denied or mutation rejected, proceed anyway
598
+ }
599
+ }
600
+ }
601
+ const fileContents = {};
602
+ for (const f of project.files) {
603
+ if (f.isMultiplayable) {
604
+ try {
605
+ const res = await this.readFile(projectId, f.name);
606
+ if (res.encoding === "utf-8" && typeof res.content === "string") {
607
+ fileContents[f.name] = res.content;
608
+ }
609
+ }
610
+ catch {
611
+ // Ignore read failures for auxiliary files
612
+ }
613
+ }
614
+ }
615
+ return captureWebDiagnostics({
616
+ appUrl: this.auth.appUrl,
617
+ projectId,
618
+ cookieHeader: this.auth.getCookieHeader(),
619
+ mainFile: effectiveMainFile,
620
+ fileContents,
621
+ });
622
+ }
623
+ async getDiagnostics(projectId, options) {
624
+ const opts = typeof options === "string" ? { mainFile: options } : options;
625
+ const mode = opts?.mode ?? "auto";
626
+ const requestedMainFile = opts?.mainFile;
627
+ if (mode === "web") {
628
+ return await this.getWebDiagnostics(projectId, requestedMainFile);
629
+ }
630
+ const typstAvailable = hasTypstBinary(this.typstBin);
631
+ if (!typstAvailable) {
632
+ if (mode === "cli") {
633
+ 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.`);
634
+ }
635
+ return await this.getWebDiagnostics(projectId, requestedMainFile);
636
+ }
485
637
  try {
486
- return compileWorkspaceDiagnostics({
487
- typstBin: this.typstBin,
488
- projectId,
489
- workspaceDir,
490
- mainFileName,
491
- });
638
+ const { workspaceDir, mainFileName } = await this.materializeProjectWorkspace(projectId, requestedMainFile);
639
+ try {
640
+ const res = compileWorkspaceDiagnostics({
641
+ typstBin: this.typstBin,
642
+ projectId,
643
+ workspaceDir,
644
+ mainFileName,
645
+ });
646
+ return {
647
+ ...res,
648
+ mode: "cli",
649
+ };
650
+ }
651
+ finally {
652
+ rmSync(workspaceDir, { recursive: true, force: true });
653
+ }
492
654
  }
493
- finally {
494
- rmSync(workspaceDir, { recursive: true, force: true });
655
+ catch (err) {
656
+ if (mode === "auto") {
657
+ return await this.getWebDiagnostics(projectId, requestedMainFile);
658
+ }
659
+ throw err;
495
660
  }
496
661
  }
497
662
  async exportPdf(projectId, options) {
@@ -542,4 +707,93 @@ export class TypstCloudClient {
542
707
  rmSync(workspaceDir, { recursive: true, force: true });
543
708
  }
544
709
  }
710
+ async grepProject(projectId, options) {
711
+ const files = await this.listFiles(projectId);
712
+ const limit = options.limit ?? 100;
713
+ const allMatches = [];
714
+ let totalMatches = 0;
715
+ let filesSearched = 0;
716
+ for (const file of files) {
717
+ if (!file.isMultiplayable)
718
+ continue;
719
+ if (options.path) {
720
+ const normalizedPath = options.path.replace(/^\/+/, "");
721
+ if (file.name !== normalizedPath &&
722
+ !file.name.startsWith(`${normalizedPath}/`) &&
723
+ file.id !== options.path) {
724
+ continue;
725
+ }
726
+ }
727
+ if (options.glob && !matchGlob(file.name, options.glob)) {
728
+ continue;
729
+ }
730
+ filesSearched++;
731
+ const yjsBytes = await this.downloadFileYjs(projectId, file.id);
732
+ const text = decodeYjsText(yjsBytes);
733
+ const remainingLimit = Math.max(0, limit - allMatches.length);
734
+ const result = searchInContent(file.name, text, {
735
+ pattern: options.pattern,
736
+ literal: options.literal,
737
+ ignoreCase: options.ignoreCase,
738
+ context: options.context,
739
+ limit: remainingLimit,
740
+ });
741
+ totalMatches += result.totalMatches;
742
+ allMatches.push(...result.matches);
743
+ }
744
+ return {
745
+ matches: allMatches,
746
+ totalMatches,
747
+ filesSearched,
748
+ truncated: totalMatches > allMatches.length,
749
+ };
750
+ }
751
+ async findAndReplace(projectId, options) {
752
+ const files = await this.listFiles(projectId);
753
+ const filesModified = [];
754
+ let totalReplacements = 0;
755
+ for (const file of files) {
756
+ if (!file.isMultiplayable)
757
+ continue;
758
+ if (options.filePath) {
759
+ const normalizedPath = options.filePath.replace(/^\/+/, "");
760
+ if (file.name !== normalizedPath && file.id !== options.filePath) {
761
+ continue;
762
+ }
763
+ }
764
+ if (options.glob && !matchGlob(file.name, options.glob)) {
765
+ continue;
766
+ }
767
+ await withFileMutationQueue(`${projectId}:${file.id}`, async () => {
768
+ const remote = await this.downloadFileYjs(projectId, file.id);
769
+ 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) {
778
+ if (!options.dryRun) {
779
+ const { encodedUpdate } = replaceYjsText(remote, repResult.updatedText);
780
+ await this.saveYjsUpdate(projectId, file.id, encodedUpdate, options.preferWebSocket ?? true);
781
+ }
782
+ filesModified.push({
783
+ file: file.name,
784
+ replacementsCount: repResult.replacementCount,
785
+ diff: repResult.diff,
786
+ patch: repResult.patch,
787
+ firstChangedLine: repResult.firstChangedLine,
788
+ });
789
+ totalReplacements += repResult.replacementCount;
790
+ }
791
+ });
792
+ }
793
+ return {
794
+ filesModified,
795
+ totalReplacements,
796
+ dryRun: Boolean(options.dryRun),
797
+ };
798
+ }
545
799
  }
package/dist/index.js CHANGED
@@ -21,6 +21,34 @@ function resolveOptionalNumber(value) {
21
21
  }
22
22
  return undefined;
23
23
  }
24
+ export function prepareEditArguments(input) {
25
+ if (!input)
26
+ return [];
27
+ if (Array.isArray(input)) {
28
+ return input.filter((e) => e && typeof e.oldText === "string" && typeof e.newText === "string");
29
+ }
30
+ if (typeof input === "string") {
31
+ 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
+ }
41
+ }
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 }];
48
+ }
49
+ }
50
+ return [];
51
+ }
24
52
  export async function dispatchTypstTool(client, name, args = {}) {
25
53
  switch (name) {
26
54
  case "typst_auth_login":
@@ -51,15 +79,28 @@ export async function dispatchTypstTool(client, name, args = {}) {
51
79
  case "typst_list_files":
52
80
  return await client.listFiles(String(args.projectId));
53
81
  case "typst_read_file":
54
- return await client.readFile(String(args.projectId), String(args.filePath));
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,
88
+ });
55
89
  case "typst_write_file":
56
90
  return await client.writeFile(String(args.projectId), String(args.filePath), String(args.content), {
57
91
  preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
58
92
  });
59
- case "typst_edit_file":
60
- return await client.editFile(String(args.projectId), String(args.filePath), args.edits ?? [], {
93
+ 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 }];
99
+ }
100
+ return await client.editFile(String(args.projectId), String(args.filePath), edits, {
61
101
  preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
62
102
  });
103
+ }
63
104
  case "typst_upload_asset": {
64
105
  let bytes;
65
106
  if (typeof args.localFilePath === "string" && args.localFilePath) {
@@ -78,7 +119,10 @@ export async function dispatchTypstTool(client, name, args = {}) {
78
119
  case "typst_rename_file":
79
120
  return await client.renameFile(String(args.projectId), String(args.filePath), String(args.newName));
80
121
  case "typst_get_diagnostics":
81
- return await client.getDiagnostics(String(args.projectId), args.mainFile);
122
+ return await client.getDiagnostics(String(args.projectId), {
123
+ mainFile: args.mainFile,
124
+ mode: args.mode,
125
+ });
82
126
  case "typst_export_pdf":
83
127
  return await client.exportPdf(String(args.projectId), {
84
128
  mainFile: args.mainFile,
@@ -93,6 +137,28 @@ export async function dispatchTypstTool(client, name, args = {}) {
93
137
  mode: args.mode ?? "pages",
94
138
  includeBase64: Boolean(args.includeBase64),
95
139
  });
140
+ case "typst_grep_project":
141
+ return await client.grepProject(String(args.projectId), {
142
+ pattern: String(args.pattern),
143
+ path: args.path,
144
+ glob: args.glob,
145
+ literal: Boolean(args.literal),
146
+ ignoreCase: Boolean(args.ignoreCase),
147
+ context: resolveOptionalNumber(args.context),
148
+ limit: resolveOptionalNumber(args.limit),
149
+ });
150
+ case "typst_find_and_replace":
151
+ return await client.findAndReplace(String(args.projectId), {
152
+ pattern: String(args.pattern),
153
+ replacement: String(args.replacement),
154
+ filePath: args.filePath,
155
+ glob: args.glob,
156
+ regex: Boolean(args.regex),
157
+ ignoreCase: Boolean(args.ignoreCase),
158
+ limit: resolveOptionalNumber(args.limit),
159
+ dryRun: Boolean(args.dryRun),
160
+ preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
161
+ });
96
162
  default:
97
163
  throw new Error(`Unknown tool: ${name}`);
98
164
  }
@@ -101,7 +167,7 @@ export function createTypstMcpServer(options = {}) {
101
167
  const client = new TypstCloudClient(options);
102
168
  const server = new Server({
103
169
  name: "typst-mcp",
104
- version: "1.0.0",
170
+ version: "1.1.0",
105
171
  }, {
106
172
  capabilities: {
107
173
  tools: {},
@@ -114,6 +180,32 @@ export function createTypstMcpServer(options = {}) {
114
180
  const { name, arguments: args } = request.params;
115
181
  try {
116
182
  const result = await dispatchTypstTool(client, name, args ?? {});
183
+ if (name === "typst_read_file" &&
184
+ typeof result === "object" &&
185
+ result !== null &&
186
+ Boolean(result.isImage) &&
187
+ typeof result.mimeType === "string" &&
188
+ typeof result.content === "string") {
189
+ const img = result;
190
+ return {
191
+ content: [
192
+ {
193
+ type: "text",
194
+ text: JSON.stringify({
195
+ file: img.file,
196
+ encoding: img.encoding,
197
+ mimeType: img.mimeType,
198
+ isImage: true,
199
+ }, null, 2),
200
+ },
201
+ {
202
+ type: "image",
203
+ data: img.content,
204
+ mimeType: img.mimeType,
205
+ },
206
+ ],
207
+ };
208
+ }
117
209
  return {
118
210
  content: [
119
211
  {