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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function hashText(text) {
|
|
3
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
4
|
+
}
|
|
5
|
+
export function validateReadOptions(options) {
|
|
6
|
+
for (const key of ["offset", "limit", "maxLines", "maxBytes"]) {
|
|
7
|
+
const value = options[key];
|
|
8
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
|
|
9
|
+
throw new Error(`${key} must be a positive integer.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
if (options.maxBytes !== undefined && options.maxBytes > 1024 * 1024) {
|
|
13
|
+
throw new Error("maxBytes must not exceed 1048576 (1 MiB). Read smaller ranges.");
|
|
14
|
+
}
|
|
15
|
+
if (options.lineNumbers !== undefined && typeof options.lineNumbers !== "boolean") {
|
|
16
|
+
throw new Error("lineNumbers must be a boolean.");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Return only complete source lines. Continuation metadata never enters source text. */
|
|
20
|
+
export function readTextContent(text, options = {}) {
|
|
21
|
+
validateReadOptions(options);
|
|
22
|
+
const lines = text.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
|
23
|
+
const totalLines = lines.length;
|
|
24
|
+
const offset = options.offset ?? 1;
|
|
25
|
+
if (offset > Math.max(1, totalLines)) {
|
|
26
|
+
throw new Error(`Offset ${offset} is beyond end of file (${totalLines} lines total)`);
|
|
27
|
+
}
|
|
28
|
+
const maxLines = Math.min(options.limit ?? 200, options.maxLines ?? Infinity);
|
|
29
|
+
const maxBytes = options.maxBytes ?? 16 * 1024;
|
|
30
|
+
const selected = [];
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
let truncatedBy = "lines";
|
|
33
|
+
const stop = Math.min(totalLines, offset - 1 + maxLines);
|
|
34
|
+
const width = String(stop).length;
|
|
35
|
+
for (let index = offset - 1; index < stop; index++) {
|
|
36
|
+
const line = lines[index];
|
|
37
|
+
const rendered = options.lineNumbers
|
|
38
|
+
? `${String(index + 1).padStart(width, " ")} | ${line}` : line;
|
|
39
|
+
const lineBytes = Buffer.byteLength(rendered, "utf8");
|
|
40
|
+
if (bytes + lineBytes > maxBytes) {
|
|
41
|
+
if (selected.length === 0) {
|
|
42
|
+
throw new Error(`Line ${offset} needs ${lineBytes} bytes. Increase maxBytes (up to 1048576) or read another line.`);
|
|
43
|
+
}
|
|
44
|
+
truncatedBy = "bytes";
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
selected.push(rendered);
|
|
48
|
+
bytes += lineBytes;
|
|
49
|
+
}
|
|
50
|
+
const endLine = offset - 1 + selected.length;
|
|
51
|
+
const truncated = endLine < totalLines;
|
|
52
|
+
return {
|
|
53
|
+
content: selected.join(""),
|
|
54
|
+
contentHash: hashText(text),
|
|
55
|
+
totalLines,
|
|
56
|
+
startLine: offset,
|
|
57
|
+
endLine,
|
|
58
|
+
truncated,
|
|
59
|
+
...(truncated ? { nextOffset: endLine + 1, truncatedBy } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -57,41 +57,118 @@ export function searchInContent(filePath, content, options) {
|
|
|
57
57
|
}
|
|
58
58
|
return { matches, totalMatches };
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (options.regex) {
|
|
66
|
-
searchRegex = new RegExp(options.pattern, flags);
|
|
60
|
+
/** Validate the entire request before any project files can be mutated. */
|
|
61
|
+
export function resolveReplacementRules(options) {
|
|
62
|
+
const hasSingle = options.pattern !== undefined || options.replacement !== undefined;
|
|
63
|
+
if (options.replacements !== undefined && hasSingle) {
|
|
64
|
+
throw new Error("Use either replacements or pattern/replacement, not both.");
|
|
67
65
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
if (options.output !== undefined && !["summary", "diff", "full"].includes(options.output)) {
|
|
67
|
+
throw new Error("output must be summary, diff, or full.");
|
|
68
|
+
}
|
|
69
|
+
if (options.replacements !== undefined &&
|
|
70
|
+
(options.regex !== undefined || options.ignoreCase !== undefined || options.limit !== undefined)) {
|
|
71
|
+
throw new Error("For batch replacements, set regex, ignoreCase, and limit on each rule.");
|
|
72
|
+
}
|
|
73
|
+
const rules = options.replacements ?? [{
|
|
74
|
+
pattern: options.pattern, replacement: options.replacement,
|
|
75
|
+
regex: options.regex, ignoreCase: options.ignoreCase, limit: options.limit,
|
|
76
|
+
}];
|
|
77
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
78
|
+
throw new Error("replacements must be a non-empty array.");
|
|
79
|
+
}
|
|
80
|
+
for (const [index, rule] of rules.entries()) {
|
|
81
|
+
if (!rule || typeof rule.pattern !== "string" || !rule.pattern.length ||
|
|
82
|
+
typeof rule.replacement !== "string") {
|
|
83
|
+
throw new Error(`Replacement ${index + 1} requires a non-empty pattern and a string replacement.`);
|
|
84
|
+
}
|
|
85
|
+
for (const key of ["regex", "ignoreCase"]) {
|
|
86
|
+
if (rule[key] !== undefined && typeof rule[key] !== "boolean") {
|
|
87
|
+
throw new Error(`Replacement ${index + 1}: ${key} must be a boolean.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (rule.limit !== undefined && (!Number.isInteger(rule.limit) || rule.limit < 0)) {
|
|
91
|
+
throw new Error(`Replacement ${index + 1}: limit must be a non-negative integer.`);
|
|
92
|
+
}
|
|
93
|
+
if (rule.regex) {
|
|
94
|
+
try {
|
|
95
|
+
new RegExp(rule.pattern, rule.ignoreCase ? "gi" : "g");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error(`Replacement ${index + 1} has an invalid regular expression.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
71
101
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
return rules;
|
|
103
|
+
}
|
|
104
|
+
export function replaceInContent(filePath, content, options) {
|
|
105
|
+
const result = replaceBatchInContent(filePath, content, [options]);
|
|
106
|
+
return { ...result, diff: result.diff, patch: result.patch };
|
|
107
|
+
}
|
|
108
|
+
/** Rules run in order on the result of the previous rule. Diff covers the whole batch. */
|
|
109
|
+
export function replaceBatchInContent(filePath, content, rules, output = "full") {
|
|
110
|
+
resolveReplacementRules({ replacements: rules, output });
|
|
111
|
+
let updatedText = content;
|
|
112
|
+
const replacementCounts = [];
|
|
113
|
+
for (const options of rules) {
|
|
114
|
+
let flags = "g";
|
|
115
|
+
if (options.ignoreCase)
|
|
116
|
+
flags += "i";
|
|
117
|
+
let searchRegex;
|
|
118
|
+
if (options.regex) {
|
|
119
|
+
searchRegex = new RegExp(options.pattern, flags);
|
|
77
120
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
121
|
+
else {
|
|
122
|
+
const escaped = options.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
123
|
+
searchRegex = new RegExp(escaped, flags);
|
|
81
124
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
125
|
+
let count = 0;
|
|
126
|
+
const limit = options.limit;
|
|
127
|
+
updatedText = updatedText.replace(searchRegex, (match, ...args) => {
|
|
128
|
+
if (limit !== undefined && count >= limit) {
|
|
129
|
+
return match;
|
|
130
|
+
}
|
|
131
|
+
count++;
|
|
132
|
+
if (!options.regex) {
|
|
133
|
+
return options.replacement;
|
|
134
|
+
}
|
|
135
|
+
// The final callback arguments are offset/input and optional named groups,
|
|
136
|
+
// not captures. Implement JS replacement tokens without exposing metadata.
|
|
137
|
+
const hasNamedGroups = typeof args[args.length - 1] === "object";
|
|
138
|
+
const groups = hasNamedGroups ? args[args.length - 1] : undefined;
|
|
139
|
+
const input = args[args.length - (hasNamedGroups ? 2 : 1)];
|
|
140
|
+
const offset = args[args.length - (hasNamedGroups ? 3 : 2)];
|
|
141
|
+
const captures = args.slice(0, hasNamedGroups ? -3 : -2);
|
|
142
|
+
return options.replacement.replace(/\$(\$|&|`|'|\d{1,2}|<[^>]*>)/g, (token, key) => {
|
|
143
|
+
if (key === "$")
|
|
144
|
+
return "$";
|
|
145
|
+
if (key === "&")
|
|
146
|
+
return match;
|
|
147
|
+
if (key === "`")
|
|
148
|
+
return input.slice(0, offset);
|
|
149
|
+
if (key === "'")
|
|
150
|
+
return input.slice(offset + match.length);
|
|
151
|
+
if (key.startsWith("<"))
|
|
152
|
+
return groups ? groups[key.slice(1, -1)] ?? "" : token;
|
|
153
|
+
const index = Number(key);
|
|
154
|
+
if (index > 0 && index <= captures.length)
|
|
155
|
+
return captures[index - 1] ?? "";
|
|
156
|
+
const first = Number(key[0]);
|
|
157
|
+
if (key.length === 2 && first > 0 && first <= captures.length) {
|
|
158
|
+
return (captures[first - 1] ?? "") + key[1];
|
|
159
|
+
}
|
|
160
|
+
return token;
|
|
161
|
+
});
|
|
86
162
|
});
|
|
87
|
-
|
|
163
|
+
replacementCounts.push(count);
|
|
164
|
+
}
|
|
88
165
|
const diffResult = generateDiffString(content, updatedText);
|
|
89
|
-
const patch = generateUnifiedPatch(filePath, content, updatedText);
|
|
90
166
|
return {
|
|
91
167
|
updatedText,
|
|
92
|
-
replacementCount: count,
|
|
93
|
-
|
|
94
|
-
|
|
168
|
+
replacementCount: replacementCounts.reduce((sum, count) => sum + count, 0),
|
|
169
|
+
replacementCounts,
|
|
170
|
+
...(output !== "summary" ? { diff: diffResult.diff } : {}),
|
|
171
|
+
...(output === "full" ? { patch: generateUnifiedPatch(filePath, content, updatedText) } : {}),
|
|
95
172
|
firstChangedLine: diffResult.firstChangedLine,
|
|
96
173
|
};
|
|
97
174
|
}
|
package/dist/protocol/yjs-doc.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as Y from "yjs";
|
|
2
|
-
import
|
|
3
|
-
import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, splitBom, } from "./edit-diff.js";
|
|
2
|
+
import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, splitBom, validateEdits, } from "./edit-diff.js";
|
|
4
3
|
function computeUpdateDiff(doc, remoteUpdate) {
|
|
5
4
|
let stateVector;
|
|
6
5
|
if (remoteUpdate.byteLength > 0) {
|
|
@@ -79,10 +78,8 @@ export function replaceYjsText(remoteUpdate, newContent) {
|
|
|
79
78
|
* directly on the `Y.Text("text")` CRDT, with CRLF/BOM resilience, fuzzy matching,
|
|
80
79
|
* and unified diff generation.
|
|
81
80
|
*/
|
|
82
|
-
export function editYjsText(remoteUpdate, edits, filePath = "file") {
|
|
83
|
-
|
|
84
|
-
throw new Error("At least one edit operation is required");
|
|
85
|
-
}
|
|
81
|
+
export function editYjsText(remoteUpdate, edits, filePath = "file", matchMode = "fuzzy") {
|
|
82
|
+
validateEdits(edits);
|
|
86
83
|
const doc = new Y.Doc();
|
|
87
84
|
try {
|
|
88
85
|
if (remoteUpdate.byteLength > 0) {
|
|
@@ -93,23 +90,50 @@ export function editYjsText(remoteUpdate, edits, filePath = "file") {
|
|
|
93
90
|
const { bom, text: contentWithoutBom } = splitBom(previousText);
|
|
94
91
|
const originalEnding = detectLineEnding(contentWithoutBom);
|
|
95
92
|
const normalizedContent = normalizeToLF(contentWithoutBom);
|
|
96
|
-
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, filePath);
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
const
|
|
93
|
+
const { baseContent, newContent, matchedEdits, usedFuzzyMatch } = applyEditsToNormalizedContent(normalizedContent, edits, filePath, matchMode);
|
|
94
|
+
// Map LF-normalized match boundaries back to original UTF-16 offsets.
|
|
95
|
+
// Preserve all untouched characters, including mixed CRLF/LF line endings.
|
|
96
|
+
const offsets = [0];
|
|
97
|
+
for (let i = 0; i < contentWithoutBom.length; i++) {
|
|
98
|
+
if (contentWithoutBom[i] === "\r" && contentWithoutBom[i + 1] === "\n")
|
|
99
|
+
i++;
|
|
100
|
+
offsets.push(i + 1);
|
|
101
|
+
}
|
|
102
|
+
const rawEdits = [...matchedEdits].reverse().map(edit => {
|
|
103
|
+
const start = offsets[edit.matchIndex];
|
|
104
|
+
const end = offsets[edit.matchIndex + edit.matchLength];
|
|
105
|
+
return { start, end, newText: restoreLineEndings(edit.newText, originalEnding) };
|
|
106
|
+
});
|
|
107
|
+
let updatedContent = contentWithoutBom;
|
|
108
|
+
for (const edit of rawEdits) {
|
|
109
|
+
updatedContent = updatedContent.slice(0, edit.start) + edit.newText + updatedContent.slice(edit.end);
|
|
110
|
+
}
|
|
111
|
+
const finalContent = bom + updatedContent;
|
|
112
|
+
// Use known edit ranges instead of diffing the entire document character by
|
|
113
|
+
// character. Retain each anchor's unchanged prefix/suffix and their CRDT IDs.
|
|
100
114
|
doc.transact(() => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
for (const edit of rawEdits) {
|
|
116
|
+
const old = contentWithoutBom.slice(edit.start, edit.end);
|
|
117
|
+
let prefix = 0;
|
|
118
|
+
while (prefix < old.length && prefix < edit.newText.length && old[prefix] === edit.newText[prefix])
|
|
119
|
+
prefix++;
|
|
120
|
+
// Never split surrogate pairs when trimming a common prefix/suffix.
|
|
121
|
+
if (prefix > 0 && /[\uD800-\uDBFF]/.test(old[prefix - 1]) && /[\uDC00-\uDFFF]/.test(old[prefix] ?? ""))
|
|
122
|
+
prefix--;
|
|
123
|
+
let suffix = 0;
|
|
124
|
+
while (suffix < old.length - prefix && suffix < edit.newText.length - prefix &&
|
|
125
|
+
old[old.length - 1 - suffix] === edit.newText[edit.newText.length - 1 - suffix])
|
|
126
|
+
suffix++;
|
|
127
|
+
if (suffix > 0 && /[\uDC00-\uDFFF]/.test(old[old.length - suffix]) &&
|
|
128
|
+
/[\uD800-\uDBFF]/.test(old[old.length - suffix - 1] ?? ""))
|
|
129
|
+
suffix--;
|
|
130
|
+
const index = bom.length + edit.start + prefix;
|
|
131
|
+
const removed = old.length - prefix - suffix;
|
|
132
|
+
const inserted = edit.newText.slice(prefix, edit.newText.length - suffix);
|
|
133
|
+
if (removed)
|
|
134
|
+
ytext.delete(index, removed);
|
|
135
|
+
if (inserted)
|
|
136
|
+
ytext.insert(index, inserted);
|
|
113
137
|
}
|
|
114
138
|
});
|
|
115
139
|
const { encodedUpdate, mergedState } = computeUpdateDiff(doc, remoteUpdate);
|
|
@@ -124,6 +148,7 @@ export function editYjsText(remoteUpdate, edits, filePath = "file") {
|
|
|
124
148
|
diff: diffResult.diff,
|
|
125
149
|
patch,
|
|
126
150
|
firstChangedLine: diffResult.firstChangedLine,
|
|
151
|
+
usedFuzzyMatch,
|
|
127
152
|
};
|
|
128
153
|
}
|
|
129
154
|
finally {
|
|
@@ -122,7 +122,7 @@ export const TYPST_MCP_TOOLS = [
|
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
124
|
name: "typst_read_file",
|
|
125
|
-
description: "Read
|
|
125
|
+
description: "Read project text in bounded pages (default 200 lines/16 KiB) or images as native image blocks. content contains source only; use nextOffset for continuation. contentHash covers the entire raw text file: pass it as expectedHash to typst_edit_file. Compact file metadata by default; output='full' adds metadata, not extra source lines. Use grep to locate relevant lines before reading large files.",
|
|
126
126
|
inputSchema: {
|
|
127
127
|
type: "object",
|
|
128
128
|
properties: {
|
|
@@ -135,12 +135,22 @@ export const TYPST_MCP_TOOLS = [
|
|
|
135
135
|
description: "File path/name (e.g. 'main.typ') or file ID",
|
|
136
136
|
},
|
|
137
137
|
offset: {
|
|
138
|
-
type: "
|
|
138
|
+
type: "integer",
|
|
139
|
+
minimum: 1,
|
|
139
140
|
description: "Line number to start reading from (1-indexed)",
|
|
140
141
|
},
|
|
141
142
|
limit: {
|
|
142
|
-
type: "
|
|
143
|
-
|
|
143
|
+
type: "integer",
|
|
144
|
+
minimum: 1,
|
|
145
|
+
description: "Maximum complete lines to read (default: 200). Use nextOffset to continue.",
|
|
146
|
+
},
|
|
147
|
+
maxBytes: {
|
|
148
|
+
type: "integer", minimum: 1, maximum: 1048576,
|
|
149
|
+
description: "Maximum returned source bytes, including line number prefixes (default: 16384, maximum: 1 MiB). Increase for unusually long lines.",
|
|
150
|
+
},
|
|
151
|
+
output: {
|
|
152
|
+
type: "string", enum: ["compact", "full"],
|
|
153
|
+
description: "compact (default): minimal file metadata; full: all file metadata. Both use offset/limit/maxBytes pagination.",
|
|
144
154
|
},
|
|
145
155
|
lineNumbers: {
|
|
146
156
|
type: "boolean",
|
|
@@ -178,7 +188,7 @@ export const TYPST_MCP_TOOLS = [
|
|
|
178
188
|
},
|
|
179
189
|
{
|
|
180
190
|
name: "typst_edit_file",
|
|
181
|
-
description: "Apply one
|
|
191
|
+
description: "Apply all targeted edits against one original file snapshot, with one save. Prefer short unique oldText anchors and expectedHash from typst_read_file to reject stale edits. Exact matching by default (LF/CRLF resilient); matchMode='fuzzy' opts into Unicode/whitespace fallback. Malformed, ambiguous, or overlapping edits fail without saving. Summary output by default; dryRun previews a bounded diff. Unchanged characters and BOM are preserved.",
|
|
182
192
|
inputSchema: {
|
|
183
193
|
type: "object",
|
|
184
194
|
properties: {
|
|
@@ -192,13 +202,15 @@ export const TYPST_MCP_TOOLS = [
|
|
|
192
202
|
},
|
|
193
203
|
edits: {
|
|
194
204
|
type: "array",
|
|
205
|
+
minItems: 1,
|
|
195
206
|
description: "One or more targeted non-overlapping replacements. Each oldText must be unique in the original file.",
|
|
196
207
|
items: {
|
|
197
208
|
type: "object",
|
|
198
209
|
properties: {
|
|
199
210
|
oldText: {
|
|
200
211
|
type: "string",
|
|
201
|
-
|
|
212
|
+
minLength: 1,
|
|
213
|
+
description: "Unique source substring to replace. All edits match the original file, not earlier edits' results.",
|
|
202
214
|
},
|
|
203
215
|
newText: {
|
|
204
216
|
type: "string",
|
|
@@ -216,12 +228,36 @@ export const TYPST_MCP_TOOLS = [
|
|
|
216
228
|
type: "string",
|
|
217
229
|
description: "Optional replacement text for single replacement shorthand",
|
|
218
230
|
},
|
|
231
|
+
expectedHash: {
|
|
232
|
+
type: "string", pattern: "^[a-fA-F0-9]{64}$",
|
|
233
|
+
description: "Recommended: contentHash from the latest read. Checked against fresh downloaded text inside the file mutation queue; stale files fail without saving.",
|
|
234
|
+
},
|
|
235
|
+
matchMode: {
|
|
236
|
+
type: "string", enum: ["exact", "fuzzy"],
|
|
237
|
+
description: "exact (default) matches source with newline normalization. fuzzy enables Unicode quote/dash/space/NFKC fallback only when an exact match is absent.",
|
|
238
|
+
},
|
|
239
|
+
dryRun: {
|
|
240
|
+
type: "boolean",
|
|
241
|
+
description: "Validate and preview edits without saving (default: false). Returned contentHash still identifies unchanged cloud text.",
|
|
242
|
+
},
|
|
243
|
+
output: {
|
|
244
|
+
type: "string", enum: ["summary", "diff", "full"],
|
|
245
|
+
description: "summary (default): counts, new contentHash, firstChangedLine; diff: also a bounded display diff; full: also bounded patch and full metadata. dryRun defaults to diff.",
|
|
246
|
+
},
|
|
247
|
+
maxDiffBytes: {
|
|
248
|
+
type: "integer", minimum: 1, maximum: 1048576,
|
|
249
|
+
description: "Byte budget per diff/patch field (default: 8192). Each also has a 100-line cap. Truncation is explicit; read affected ranges for full details.",
|
|
250
|
+
},
|
|
219
251
|
preferWebSocket: {
|
|
220
252
|
type: "boolean",
|
|
221
253
|
description: "Push real-time TextUpdate over /editing/v3 WebSocket in addition to REST persistence (default: true)",
|
|
222
254
|
},
|
|
223
255
|
},
|
|
224
256
|
required: ["projectId", "filePath"],
|
|
257
|
+
oneOf: [
|
|
258
|
+
{ required: ["edits"], not: { anyOf: [{ required: ["oldText"] }, { required: ["newText"] }] } },
|
|
259
|
+
{ required: ["oldText", "newText"], not: { required: ["edits"] } },
|
|
260
|
+
],
|
|
225
261
|
},
|
|
226
262
|
},
|
|
227
263
|
{
|
|
@@ -296,7 +332,7 @@ export const TYPST_MCP_TOOLS = [
|
|
|
296
332
|
},
|
|
297
333
|
{
|
|
298
334
|
name: "typst_get_diagnostics",
|
|
299
|
-
description: "
|
|
335
|
+
description: "Retrieve compiler diagnostics (errors, warnings, line/column numbers, and hints) for a https://typst.app project. Supports both local Typst CLI compilation (fastest) and Typst Web cloud diagnostics (zero-binary fallback using typst.app cloud compiler).",
|
|
300
336
|
inputSchema: {
|
|
301
337
|
type: "object",
|
|
302
338
|
properties: {
|
|
@@ -308,6 +344,11 @@ export const TYPST_MCP_TOOLS = [
|
|
|
308
344
|
type: "string",
|
|
309
345
|
description: "Optional main .typ entry file override (defaults to project previewed file or main.typ)",
|
|
310
346
|
},
|
|
347
|
+
mode: {
|
|
348
|
+
type: "string",
|
|
349
|
+
enum: ["auto", "cli", "web"],
|
|
350
|
+
description: "Diagnostics mode: 'auto' (prefers local Typst CLI if installed, falls back to Typst Web cloud compiler), 'cli' (requires local typst binary), or 'web' (uses Typst Web cloud compiler). Defaults to 'auto'.",
|
|
351
|
+
},
|
|
311
352
|
},
|
|
312
353
|
required: ["projectId"],
|
|
313
354
|
},
|
|
@@ -417,7 +458,7 @@ export const TYPST_MCP_TOOLS = [
|
|
|
417
458
|
},
|
|
418
459
|
{
|
|
419
460
|
name: "typst_find_and_replace",
|
|
420
|
-
description: "Find and replace text or
|
|
461
|
+
description: "Find and replace text or regex in one file or project-wide. Use replacements to batch mappings (e.g. Sep → September, Oct → October) in one call with one save per file. Rules run sequentially; later rules see earlier results. Batch output defaults to compact summary; request output='diff' or 'full' for previews. Supports dryRun and per-file mutation serialization. Project-wide changes are not atomic across files.",
|
|
421
462
|
inputSchema: {
|
|
422
463
|
type: "object",
|
|
423
464
|
properties: {
|
|
@@ -433,6 +474,27 @@ export const TYPST_MCP_TOOLS = [
|
|
|
433
474
|
type: "string",
|
|
434
475
|
description: "Replacement text (supports $1, $2 capture groups if regex is true)",
|
|
435
476
|
},
|
|
477
|
+
replacements: {
|
|
478
|
+
type: "array",
|
|
479
|
+
minItems: 1,
|
|
480
|
+
description: "Ordered replacement rules instead of pattern/replacement. Each rule runs on the previous result. Set regex, ignoreCase, and limit per rule. All rules are validated before any writes; each changed file is saved once.",
|
|
481
|
+
items: {
|
|
482
|
+
type: "object",
|
|
483
|
+
properties: {
|
|
484
|
+
pattern: { type: "string", minLength: 1 },
|
|
485
|
+
replacement: { type: "string" },
|
|
486
|
+
regex: { type: "boolean", description: "Treat pattern as regex (default: false)" },
|
|
487
|
+
ignoreCase: { type: "boolean", description: "Case-insensitive matching (default: false)" },
|
|
488
|
+
limit: { type: "integer", minimum: 0, description: "Maximum replacements for this rule per file" },
|
|
489
|
+
},
|
|
490
|
+
required: ["pattern", "replacement"],
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
output: {
|
|
494
|
+
type: "string",
|
|
495
|
+
enum: ["summary", "diff", "full"],
|
|
496
|
+
description: "summary: counts and first changed line; diff: also display diff; full: also unified patch. Defaults to summary for batches, full for legacy pattern/replacement calls. Applies to dry runs too.",
|
|
497
|
+
},
|
|
436
498
|
filePath: {
|
|
437
499
|
type: "string",
|
|
438
500
|
description: "Optional file path/name (e.g. 'main.typ'). If omitted, searches and replaces across all text files in the project.",
|
|
@@ -462,7 +524,11 @@ export const TYPST_MCP_TOOLS = [
|
|
|
462
524
|
description: "Push real-time TextUpdate over /editing/v3 WebSocket before REST persistence (default: true)",
|
|
463
525
|
},
|
|
464
526
|
},
|
|
465
|
-
required: ["projectId"
|
|
527
|
+
required: ["projectId"],
|
|
528
|
+
oneOf: [
|
|
529
|
+
{ required: ["pattern", "replacement"], not: { required: ["replacements"] } },
|
|
530
|
+
{ required: ["replacements"], not: { anyOf: [{ required: ["pattern"] }, { required: ["replacement"] }, { required: ["regex"] }, { required: ["ignoreCase"] }, { required: ["limit"] }] } },
|
|
531
|
+
],
|
|
466
532
|
},
|
|
467
533
|
},
|
|
468
534
|
];
|
package/package.json
CHANGED