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 +57 -58
- package/dist/client/typst-client.js +220 -40
- package/dist/index.js +93 -4
- package/dist/protocol/edit-diff.js +330 -0
- package/dist/protocol/search-replace.js +97 -0
- package/dist/protocol/yjs-doc.js +31 -38
- package/dist/tools/definitions.js +117 -5
- package/dist/utils/file-mutation-queue.js +30 -0
- package/dist/utils/mime.js +23 -0
- package/dist/utils/truncate.js +114 -0
- package/package.json +3 -1
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
|
[](https://www.npmjs.com/package/typst-mcp)
|
|
10
8
|
[](https://www.npmjs.com/package/typst-mcp)
|
|
11
9
|
[](./LICENSE)
|
|
12
|
-
[](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/ci.yml)
|
|
13
10
|
[](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/npm-publish.yml)
|
|
14
|
-
[](https://bun.sh)
|
|
15
|
-
[](https://www.typescriptlang.org/)
|
|
16
|
-
[](https://modelcontextprotocol.io)
|
|
17
|
-
[](./docs/PROTOCOL.md)
|
|
18
|
-
[-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:
|
|
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
|
-
|
|
348
|
-
files
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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:
|
|
450
|
+
file: updatedMeta,
|
|
356
451
|
created: false,
|
|
357
|
-
bytesWritten:
|
|
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("
|
|
363
|
-
|
|
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:
|
|
366
|
-
created:
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
{
|