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.
- package/README.md +115 -72
- package/dist/client/auth.js +36 -3
- package/dist/client/compiler.js +234 -6
- package/dist/client/typst-client.js +135 -112
- package/dist/index.js +84 -41
- package/dist/protocol/edit-diff.js +74 -23
- package/dist/protocol/read-content.js +61 -0
- package/dist/protocol/search-replace.js +104 -27
- package/dist/protocol/yjs-doc.js +47 -22
- package/dist/tools/definitions.js +75 -9
- package/package.json +1 -1
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
479
|
-
|
|
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
|
|
575
|
-
const
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
585
|
-
|
|
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 =
|
|
697
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
95
|
-
if (edits
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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),
|
|
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:
|
|
150
|
-
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:
|
|
154
|
-
ignoreCase:
|
|
155
|
-
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.
|
|
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 =
|
|
136
|
+
const { normalized: fuzzyContent, starts, ends } = fuzzyTextWithOffsets(content);
|
|
137
137
|
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
|
138
|
-
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
|
|
139
|
-
|
|
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:
|
|
153
|
+
index: starts[fuzzyIndex],
|
|
154
|
+
matchLength: ends[last] - starts[fuzzyIndex],
|
|
152
155
|
usedFuzzyMatch: true,
|
|
153
|
-
contentForReplacement:
|
|
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
|
-
|
|
158
|
-
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
|
159
|
-
if (fuzzyOldText.length === 0)
|
|
194
|
+
if (!oldText.length)
|
|
160
195
|
return 0;
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
293
|
+
if (firstChangedLine === undefined) {
|
|
243
294
|
firstChangedLine = newLineNum;
|
|
244
295
|
}
|
|
245
296
|
for (const line of raw) {
|