typst-mcp 1.0.2 → 1.1.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
@@ -4,73 +4,15 @@
4
4
 
5
5
  **Model Context Protocol (MCP) server for [`https://typst.app`](https://typst.app) — cloud projects, Yjs CRDT editing, and Typst compiler diagnostics**
6
6
 
7
- Install from npm as [`typst-mcp`](https://www.npmjs.com/package/typst-mcp). Source repo: [`typst-web-mcp`](https://github.com/caphefalumi/typst-web-mcp).
8
-
9
7
  [![npm version](https://img.shields.io/npm/v/typst-mcp.svg?style=flat-square)](https://www.npmjs.com/package/typst-mcp)
10
8
  [![Downloads](https://img.shields.io/npm/dt/typst-mcp.svg?style=flat-square)](https://www.npmjs.com/package/typst-mcp)
11
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](./LICENSE)
12
- [![CI](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/ci.yml)
13
10
  [![Node.js Package](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/npm-publish.yml/badge.svg)](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/npm-publish.yml)
14
- [![Runtime: Bun](https://img.shields.io/badge/Runtime-Bun_%E2%89%A51.2-f9f1e1?style=flat-square&logo=bun&logoColor=black)](https://bun.sh)
15
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
16
- [![MCP SDK](https://img.shields.io/badge/MCP-2024--11--05-10a37f?style=flat-square)](https://modelcontextprotocol.io)
17
- [![Protocol: Yjs + lib0](https://img.shields.io/badge/CRDT-Yjs_%2B_lib0_v3-ff69b4?style=flat-square)](./docs/PROTOCOL.md)
18
- [![Tests](https://img.shields.io/badge/Tests-7_Passing_(57_Assertions)-2ea44f?style=flat-square)](./test/typst-mcp.test.ts)
19
11
 
20
12
  </div>
21
13
 
22
14
  ---
23
15
 
24
- ## Overview
25
-
26
- `typst-web-mcp` connects AI coding and writing agents directly to collaborative [**typst.app**](https://typst.app) cloud projects. Instead of relying on brittle browser DOM scraping, it implements the native `typst.app` client protocol:
27
-
28
- - **Native Yjs CRDT Text Sync:** Decodes and mutates multiplayer `Y.Doc` (`doc.getText("text")`) state vectors so surgical edits preserve collaborator cursors and version history.
29
- - **Binary `/editing/v3` WebSocket Wire Protocol:** Encodes and decodes `lib0` variable-length binary frames (`Subscribe`, `TextUpdate`, `Pong`, `Refresh`, `Presence`, `Cursor`, `Leave`) with REST `/v1/files/:projectId/:fileId/yjs` persistence.
30
- - **GraphQL & REST Project Management:** Full project CRUD, file tree inspection, rename/move, delete, and binary figure/asset uploads (`PNG`, `JPG`, `SVG`, `PDF`, fonts).
31
- - **Direct Authentication + Fallback:** Authenticates via `GET /v1/to-signin` + `POST /v1/auth/signin` (`__Http-typst-session`), with persistent session caching and automatic cookie/Playwright fallback on challenge.
32
- - **Compiler Diagnostics, Preview Screenshots & PDF Export:** Synchronizes cloud Yjs text and binary assets into an isolated workspace and runs the Typst compiler to return structured line/column diagnostics, high-DPI PNG preview screenshots (`typst_capture_screenshot`), and compiled PDFs (`typst_export_pdf`).
33
-
34
- ---
35
-
36
- ## Architecture
37
-
38
- ```mermaid
39
- flowchart LR
40
- Agent["AI Agent\n(Pi / Claude / Cursor)"] <-->|"JSON-RPC (stdio)"| MCP["typst-mcp\n(Node / TypeScript)"]
41
- MCP <-->|"GraphQL (/v1/graphql)\n+ X-Typst-Preflight"| API["https://api.typst.app"]
42
- MCP <-->|"REST Yjs & Blobs\n(/v1/files/:pid/:fid/yjs)"| API
43
- MCP <-->|"Binary lib0 WebSocket\n(wss://api.typst.app/editing/v3)"| WS["typst.app Real-Time Sync"]
44
- MCP -->|"Workspace Sync + Compile"| TypstCLI["Typst Compiler\n(Diagnostics & PDF)"]
45
- ```
46
-
47
- See [`docs/PROTOCOL.md`](./docs/PROTOCOL.md) for the complete reverse-engineered protocol specification.
48
-
49
- ---
50
-
51
- ## MCP Tools (16 Tools)
52
-
53
- | Category | Tool Name | Description |
54
- |:---|:---|:---|
55
- | **Auth** | `typst_auth_login` | Authenticate with `https://typst.app` via email/password (`POST /v1/auth/signin`) or `__Http-typst-session` cookie, with browser fallback. |
56
- | **Auth** | `typst_auth_status` | Check active authentication state, login method, and stored session cookies. |
57
- | **Projects** | `typst_list_projects` | List all owned and shared/external projects in the user's `typst.app` dashboard (`DashboardContent`). |
58
- | **Projects** | `typst_get_project` | Retrieve full project metadata, access permissions, pinned compiler version, and file tree (`ProjectFiles`). |
59
- | **Projects** | `typst_create_project` | Create a new `typst.app` cloud project with initial `.typ`/`.bib` files and pinned compiler version. |
60
- | **Projects** | `typst_delete_project` | Permanently delete a `typst.app` project by ID (`DeleteProjectMutation`). |
61
- | **Files & CRDT** | `typst_list_files` | List all multiplayer Yjs text files and binary asset blobs in a project. |
62
- | **Files & CRDT** | `typst_read_file` | Read any project file — decodes binary Yjs CRDT state (`doc.getText("text")`) into UTF-8 text or base64 for binary assets. |
63
- | **Files & CRDT** | `typst_write_file` | Create or overwrite a `.typ`/`.bib`/`.toml` file, syncing Yjs state diffs over `/editing/v3` WebSocket and REST. |
64
- | **Files & CRDT** | `typst_edit_file` | Apply non-overlapping surgical `oldText -> newText` replacements directly to the `Y.Text("text")` CRDT. |
65
- | **Assets & Tree** | `typst_upload_asset` | Upload or replace binary assets (`PNG`, `JPG`, `SVG`, `PDF`, fonts) from a local path or base64 payload. |
66
- | **Assets & Tree** | `typst_rename_file` | Rename or move a file inside a `typst.app` project (`UpdatesFileMutation`). |
67
- | **Assets & Tree** | `typst_delete_file` | Delete a file or binary asset from a project (`DeleteFilesMutation`). |
68
- | **Compiler** | `typst_get_diagnostics` | Sync all cloud files and run `typst compile` to return structured errors, warnings, line/column spans, and hints. |
69
- | **Compiler** | `typst_export_pdf` | Sync all cloud text & binary assets, compile the project to PDF, and save to `outputPath`. |
70
- | **Preview** | `typst_capture_screenshot` | Capture high-DPI PNG preview screenshots (`mode="pages"` renders compiled PNG pages; `mode="browser"` screenshots the live `typst.app` web UI). |
71
-
72
- ---
73
-
74
16
  ## Quick Start
75
17
 
76
18
  ### Prerequisites
@@ -120,6 +62,58 @@ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`,
120
62
 
121
63
  ---
122
64
 
65
+ ## Overview
66
+
67
+ `typst-web-mcp` connects AI coding and writing agents directly to collaborative [**typst.app**](https://typst.app) cloud projects. Instead of relying on brittle browser DOM scraping, it implements the native `typst.app` client protocol:
68
+
69
+ - **Native Yjs CRDT Text Sync:** Decodes and mutates multiplayer `Y.Doc` (`doc.getText("text")`) state vectors so surgical edits preserve collaborator cursors and version history.
70
+ - **Binary `/editing/v3` WebSocket Wire Protocol:** Encodes and decodes `lib0` variable-length binary frames (`Subscribe`, `TextUpdate`, `Pong`, `Refresh`, `Presence`, `Cursor`, `Leave`) with REST `/v1/files/:projectId/:fileId/yjs` persistence.
71
+ - **GraphQL & REST Project Management:** Full project CRUD, file tree inspection, rename/move, delete, and binary figure/asset uploads (`PNG`, `JPG`, `SVG`, `PDF`, fonts).
72
+ - **Direct Authentication + Fallback:** Authenticates via `GET /v1/to-signin` + `POST /v1/auth/signin` (`__Http-typst-session`), with persistent session caching and automatic cookie/Playwright fallback on challenge.
73
+ - **Compiler Diagnostics, Preview Screenshots & PDF Export:** Synchronizes cloud Yjs text and binary assets into an isolated workspace and runs the Typst compiler to return structured line/column diagnostics, high-DPI PNG preview screenshots (`typst_capture_screenshot`), and compiled PDFs (`typst_export_pdf`).
74
+
75
+ ---
76
+
77
+ ## Architecture
78
+
79
+ ```mermaid
80
+ flowchart LR
81
+ Agent["AI Agent\n(Pi / Claude / Cursor)"] <-->|"JSON-RPC (stdio)"| MCP["typst-mcp\n(Node / TypeScript)"]
82
+ MCP <-->|"GraphQL (/v1/graphql)\n+ X-Typst-Preflight"| API["https://api.typst.app"]
83
+ MCP <-->|"REST Yjs & Blobs\n(/v1/files/:pid/:fid/yjs)"| API
84
+ MCP <-->|"Binary lib0 WebSocket\n(wss://api.typst.app/editing/v3)"| WS["typst.app Real-Time Sync"]
85
+ MCP -->|"Workspace Sync + Compile"| TypstCLI["Typst Compiler\n(Diagnostics & PDF)"]
86
+ ```
87
+
88
+ See [`docs/PROTOCOL.md`](./docs/PROTOCOL.md) for the complete reverse-engineered protocol specification.
89
+
90
+ ---
91
+
92
+ ## MCP Tools (18 Tools)
93
+
94
+ | Category | Tool Name | Description |
95
+ |:---|:---|:---|
96
+ | **Auth** | `typst_auth_login` | Authenticate with `https://typst.app` via email/password (`POST /v1/auth/signin`) or `__Http-typst-session` cookie, with browser fallback. |
97
+ | **Auth** | `typst_auth_status` | Check active authentication state, login method, and stored session cookies. |
98
+ | **Projects** | `typst_list_projects` | List all owned and shared/external projects in the user's `typst.app` dashboard (`DashboardContent`). |
99
+ | **Projects** | `typst_get_project` | Retrieve full project metadata, access permissions, pinned compiler version, and file tree (`ProjectFiles`). |
100
+ | **Projects** | `typst_create_project` | Create a new `typst.app` cloud project with initial `.typ`/`.bib` files and pinned compiler version. |
101
+ | **Projects** | `typst_delete_project` | Permanently delete a `typst.app` project by ID (`DeleteProjectMutation`). |
102
+ | **Files & CRDT** | `typst_list_files` | List all multiplayer Yjs text files and binary asset blobs in a project. |
103
+ | **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`). |
104
+ | **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. |
105
+ | **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. |
106
+ | **Search & Replace** | `typst_grep_project` | Search for text or regular expressions across project files, returning file paths, line numbers, and optional surrounding context lines. |
107
+ | **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. |
108
+ | **Assets & Tree** | `typst_upload_asset` | Upload or replace binary assets (`PNG`, `JPG`, `SVG`, `PDF`, fonts) from a local path or base64 payload. |
109
+ | **Assets & Tree** | `typst_rename_file` | Rename or move a file inside a `typst.app` project (`UpdatesFileMutation`). |
110
+ | **Assets & Tree** | `typst_delete_file` | Delete a file or binary asset from a project (`DeleteFilesMutation`). |
111
+ | **Compiler** | `typst_get_diagnostics` | Sync all cloud files and run `typst compile` to return structured errors, warnings, line/column spans, and hints. |
112
+ | **Compiler** | `typst_export_pdf` | Sync all cloud text & binary assets, compile the project to PDF, and save to `outputPath`. |
113
+ | **Preview** | `typst_capture_screenshot` | Capture high-DPI PNG preview screenshots (`mode="pages"` renders compiled PNG pages; `mode="browser"` screenshots the live `typst.app` web UI). |
114
+
115
+ ---
116
+
123
117
  ## Development & Verification
124
118
 
125
119
  ```bash
@@ -157,6 +151,7 @@ typst-web-mcp/
157
151
  │ └── PROTOCOL.md # Reverse-engineered typst.app REST, GraphQL, Yjs & WS v3 spec
158
152
  ├── src/
159
153
  │ ├── protocol/
154
+ │ │ ├── edit-diff.ts # Fuzzy matching, CRLF/BOM resilience, unified diff & patch generator
160
155
  │ │ ├── ws-codec.ts # Binary lib0 /editing/v3 WebSocket encoder & decoder
161
156
  │ │ ├── yjs-doc.ts # Yjs CRDT text decoding, replacement & surgical patching
162
157
  │ │ └── graphql.ts # GraphQL queries, mutations, fragments & types
@@ -167,6 +162,10 @@ typst-web-mcp/
167
162
  │ │ └── typst-client.ts # TypstCloudClient (REST, GraphQL, WebSocket v3, Compiler facade)
168
163
  │ ├── tools/
169
164
  │ │ └── definitions.ts # MCP JSON Schema definitions for all 16 tools
165
+ │ ├── utils/
166
+ │ │ ├── file-mutation-queue.ts # Serialized mutation mutex queue per file
167
+ │ │ ├── mime.ts # Image MIME type detection
168
+ │ │ └── truncate.ts # Dual-limit (line & byte) head/tail truncation utilities
170
169
  │ └── index.ts # MCP stdio server entrypoint (dev); npm bin runs dist/index.js
171
170
  ├── test/
172
171
  │ └── typst-mcp.test.ts # 7 test suites (57 assertions) covering codecs, CRDTs & E2E
@@ -7,6 +7,10 @@ import { captureBrowserPreviewScreenshot, captureWorkspacePageScreenshots, compi
7
7
  import { CREATE_PROJECT_MUTATION, DASHBOARD_CONTENT_QUERY, DELETE_FILES_MUTATION, DELETE_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(/^\/+/, "");
@@ -542,4 +633,93 @@ export class TypstCloudClient {
542
633
  rmSync(workspaceDir, { recursive: true, force: true });
543
634
  }
544
635
  }
636
+ async grepProject(projectId, options) {
637
+ const files = await this.listFiles(projectId);
638
+ const limit = options.limit ?? 100;
639
+ const allMatches = [];
640
+ let totalMatches = 0;
641
+ let filesSearched = 0;
642
+ for (const file of files) {
643
+ if (!file.isMultiplayable)
644
+ continue;
645
+ if (options.path) {
646
+ const normalizedPath = options.path.replace(/^\/+/, "");
647
+ if (file.name !== normalizedPath &&
648
+ !file.name.startsWith(`${normalizedPath}/`) &&
649
+ file.id !== options.path) {
650
+ continue;
651
+ }
652
+ }
653
+ if (options.glob && !matchGlob(file.name, options.glob)) {
654
+ continue;
655
+ }
656
+ filesSearched++;
657
+ const yjsBytes = await this.downloadFileYjs(projectId, file.id);
658
+ const text = decodeYjsText(yjsBytes);
659
+ const remainingLimit = Math.max(0, limit - allMatches.length);
660
+ const result = searchInContent(file.name, text, {
661
+ pattern: options.pattern,
662
+ literal: options.literal,
663
+ ignoreCase: options.ignoreCase,
664
+ context: options.context,
665
+ limit: remainingLimit,
666
+ });
667
+ totalMatches += result.totalMatches;
668
+ allMatches.push(...result.matches);
669
+ }
670
+ return {
671
+ matches: allMatches,
672
+ totalMatches,
673
+ filesSearched,
674
+ truncated: totalMatches > allMatches.length,
675
+ };
676
+ }
677
+ async findAndReplace(projectId, options) {
678
+ const files = await this.listFiles(projectId);
679
+ const filesModified = [];
680
+ let totalReplacements = 0;
681
+ for (const file of files) {
682
+ if (!file.isMultiplayable)
683
+ continue;
684
+ if (options.filePath) {
685
+ const normalizedPath = options.filePath.replace(/^\/+/, "");
686
+ if (file.name !== normalizedPath && file.id !== options.filePath) {
687
+ continue;
688
+ }
689
+ }
690
+ if (options.glob && !matchGlob(file.name, options.glob)) {
691
+ continue;
692
+ }
693
+ await withFileMutationQueue(`${projectId}:${file.id}`, async () => {
694
+ const remote = await this.downloadFileYjs(projectId, file.id);
695
+ 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) {
704
+ if (!options.dryRun) {
705
+ const { encodedUpdate } = replaceYjsText(remote, repResult.updatedText);
706
+ await this.saveYjsUpdate(projectId, file.id, encodedUpdate, options.preferWebSocket ?? true);
707
+ }
708
+ filesModified.push({
709
+ file: file.name,
710
+ replacementsCount: repResult.replacementCount,
711
+ diff: repResult.diff,
712
+ patch: repResult.patch,
713
+ firstChangedLine: repResult.firstChangedLine,
714
+ });
715
+ totalReplacements += repResult.replacementCount;
716
+ }
717
+ });
718
+ }
719
+ return {
720
+ filesModified,
721
+ totalReplacements,
722
+ dryRun: Boolean(options.dryRun),
723
+ };
724
+ }
545
725
  }
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) {
@@ -93,6 +134,28 @@ export async function dispatchTypstTool(client, name, args = {}) {
93
134
  mode: args.mode ?? "pages",
94
135
  includeBase64: Boolean(args.includeBase64),
95
136
  });
137
+ case "typst_grep_project":
138
+ return await client.grepProject(String(args.projectId), {
139
+ pattern: String(args.pattern),
140
+ path: args.path,
141
+ glob: args.glob,
142
+ literal: Boolean(args.literal),
143
+ ignoreCase: Boolean(args.ignoreCase),
144
+ context: resolveOptionalNumber(args.context),
145
+ limit: resolveOptionalNumber(args.limit),
146
+ });
147
+ case "typst_find_and_replace":
148
+ return await client.findAndReplace(String(args.projectId), {
149
+ pattern: String(args.pattern),
150
+ replacement: String(args.replacement),
151
+ filePath: args.filePath,
152
+ glob: args.glob,
153
+ regex: Boolean(args.regex),
154
+ ignoreCase: Boolean(args.ignoreCase),
155
+ limit: resolveOptionalNumber(args.limit),
156
+ dryRun: Boolean(args.dryRun),
157
+ preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
158
+ });
96
159
  default:
97
160
  throw new Error(`Unknown tool: ${name}`);
98
161
  }
@@ -101,7 +164,7 @@ export function createTypstMcpServer(options = {}) {
101
164
  const client = new TypstCloudClient(options);
102
165
  const server = new Server({
103
166
  name: "typst-mcp",
104
- version: "1.0.0",
167
+ version: "1.1.0",
105
168
  }, {
106
169
  capabilities: {
107
170
  tools: {},
@@ -114,6 +177,32 @@ export function createTypstMcpServer(options = {}) {
114
177
  const { name, arguments: args } = request.params;
115
178
  try {
116
179
  const result = await dispatchTypstTool(client, name, args ?? {});
180
+ if (name === "typst_read_file" &&
181
+ typeof result === "object" &&
182
+ result !== null &&
183
+ Boolean(result.isImage) &&
184
+ typeof result.mimeType === "string" &&
185
+ typeof result.content === "string") {
186
+ const img = result;
187
+ return {
188
+ content: [
189
+ {
190
+ type: "text",
191
+ text: JSON.stringify({
192
+ file: img.file,
193
+ encoding: img.encoding,
194
+ mimeType: img.mimeType,
195
+ isImage: true,
196
+ }, null, 2),
197
+ },
198
+ {
199
+ type: "image",
200
+ data: img.content,
201
+ mimeType: img.mimeType,
202
+ },
203
+ ],
204
+ };
205
+ }
117
206
  return {
118
207
  content: [
119
208
  {