@stll/folio-cli 0.3.3 → 0.4.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 +37 -9
- package/dist/batch-operations.d.ts +29 -0
- package/dist/batch-operations.js +226 -0
- package/dist/document.d.ts +16 -2
- package/dist/document.js +32 -8
- package/dist/execute-read.d.ts +14 -10
- package/dist/execute-read.js +27 -15
- package/dist/execute-write.d.ts +1 -1
- package/dist/execute-write.js +36 -16
- package/dist/mcp-kit/envelope.d.ts +27 -0
- package/dist/mcp-kit/envelope.js +80 -0
- package/dist/mcp-kit/index.d.ts +6 -0
- package/dist/mcp-kit/index.js +5 -0
- package/dist/mcp-kit/input.d.ts +23 -0
- package/dist/mcp-kit/input.js +76 -0
- package/dist/mcp-kit/schema.d.ts +20 -0
- package/dist/mcp-kit/schema.js +97 -0
- package/dist/mcp-kit/surface.d.ts +19 -0
- package/dist/mcp-kit/surface.js +312 -0
- package/dist/mcp-kit/types.d.ts +98 -0
- package/dist/mcp-kit/types.js +1 -0
- package/dist/mcp.d.ts +8 -2
- package/dist/mcp.js +486 -72
- package/dist/package.js +1 -1
- package/dist/registry.js +4 -2
- package/dist/save.d.ts +1 -1
- package/package.json +3 -2
package/dist/mcp.js
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
2
|
import { executeReadTool } from "./execute-read.js";
|
|
3
3
|
import { executeWriteTool } from "./execute-write.js";
|
|
4
|
-
import {
|
|
4
|
+
import { failure, success } from "./mcp-kit/envelope.js";
|
|
5
|
+
import { compactSchema, hoistRepeatedSchemas } from "./mcp-kit/schema.js";
|
|
6
|
+
import { createToolSurface } from "./mcp-kit/surface.js";
|
|
5
7
|
import { version } from "./package.js";
|
|
6
|
-
import { FOLIO_FILE_TOOLS,
|
|
8
|
+
import { FOLIO_FILE_TOOLS, toolAccess } from "./registry.js";
|
|
7
9
|
import { checkWithinRoots } from "./roots.js";
|
|
8
10
|
import { Result, panic } from "better-result";
|
|
11
|
+
import { SUGGEST_CHANGES_OPERATION_TYPES, describeSuggestChangesCapabilities } from "@stll/folio-agents/tools";
|
|
12
|
+
import { FOLIO_DOCUMENT_OPERATION_KEYS_BY_TYPE } from "@stll/folio-core/server";
|
|
9
13
|
import { FOLIO_DOCUMENT_OPERATION_BATCH_JSON_SCHEMA } from "@stll/folio-agents/operation-schema";
|
|
10
14
|
import { ProtocolError, ProtocolErrorCode, Server } from "@modelcontextprotocol/server";
|
|
11
15
|
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
12
16
|
//#region src/mcp.ts
|
|
13
17
|
/**
|
|
14
18
|
* `folio mcp`: the file tool registry as a Model Context Protocol server over
|
|
15
|
-
* stdio. The protocol owns stdout; diagnostics go to stderr.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* stdio. The protocol owns stdout; diagnostics go to stderr.
|
|
20
|
+
*
|
|
21
|
+
* A model pays for `tools/list` on every turn, so the server lists only the
|
|
22
|
+
* frequent tools, with compact schemas, and reaches the rest through
|
|
23
|
+
* `list_capabilities` / `describe_capability` / `invoke_capability`
|
|
24
|
+
* (`mcp-kit`). Every tool takes the file envelope (`path`, `fileVersion`, and
|
|
25
|
+
* for writes `destination`, `txId`, ...) beside its own arguments; the listed
|
|
26
|
+
* schemas show the part a call usually needs and `describe_capability` shows
|
|
27
|
+
* all of it. Every path must resolve inside an allowed root. Results carry
|
|
28
|
+
* what the next call needs (the new `fileVersion`, ids, counts); a failure is
|
|
29
|
+
* `{ error: { code, message, hint, retryable } }`.
|
|
20
30
|
*/
|
|
21
31
|
/** Limits on what one MCP tool call returns. */
|
|
22
32
|
const MCP_READ_BOUNDS = {
|
|
@@ -26,20 +36,21 @@ const MCP_READ_BOUNDS = {
|
|
|
26
36
|
};
|
|
27
37
|
const ABOUT_URI = "folio://about";
|
|
28
38
|
const OPERATIONS_SCHEMA_URI = "folio://schema/operations";
|
|
29
|
-
const
|
|
39
|
+
const instructionsFor = (author) => "Reads and edits .docx files. read_document returns the fileVersion and a `[blockId] text` line per block; a write needs the latest fileVersion and returns the next. Block ids stay valid across edits, so chain calls without re-reading. Edits are tracked changes. One suggest_changes batch can do a whole review: replaceAll {find, replace} renames everywhere, tables included; addComment {comment, quote or blockId} comments alongside. A successful write is authoritative (fileVersion, author, applied counts): no verification read or shell check is needed. " + (author === void 0 ? "No author is configured, so writes are refused. " : `Every change and comment is recorded under the author ${JSON.stringify(author)}. `) + "Failures are { error: { code, message, hint, retryable } }.";
|
|
30
40
|
const PATH_PROPERTY = {
|
|
31
41
|
type: "string",
|
|
32
42
|
description: "The .docx file, absolute or relative to the first allowed root."
|
|
33
43
|
};
|
|
44
|
+
const FILE_VERSION_PATTERN = "^[0-9a-f]{64}$";
|
|
34
45
|
const envelopeProperties = (tool) => {
|
|
35
46
|
const access = toolAccess(tool);
|
|
36
47
|
const properties = {
|
|
37
48
|
path: PATH_PROPERTY,
|
|
38
49
|
fileVersion: {
|
|
39
50
|
type: "string",
|
|
40
|
-
pattern:
|
|
51
|
+
pattern: FILE_VERSION_PATTERN,
|
|
41
52
|
description: {
|
|
42
|
-
write: "The file's fileVersion (SHA-256) from your latest read. Required: a change to a file that moved on is refused.",
|
|
53
|
+
write: "The file's fileVersion (SHA-256) from your latest read or write. Required: a change to a file that moved on is refused.",
|
|
43
54
|
readOrWrite: "The file's fileVersion (SHA-256) from your latest read; required with `destination`.",
|
|
44
55
|
read: "When given, refuse unless the file still has this fileVersion."
|
|
45
56
|
}[access]
|
|
@@ -57,13 +68,13 @@ const envelopeProperties = (tool) => {
|
|
|
57
68
|
},
|
|
58
69
|
expectedDestinationVersion: {
|
|
59
70
|
type: "string",
|
|
60
|
-
pattern:
|
|
71
|
+
pattern: FILE_VERSION_PATTERN,
|
|
61
72
|
description: "The fileVersion of the existing file `destination` replaces."
|
|
62
73
|
},
|
|
63
74
|
txId: {
|
|
64
75
|
type: "string",
|
|
65
76
|
pattern: "^[\\w.-]{1,128}$",
|
|
66
|
-
description: "Idempotency key: repeating a committed call returns its original
|
|
77
|
+
description: "Idempotency key: repeating a committed call returns its original result."
|
|
67
78
|
}
|
|
68
79
|
});
|
|
69
80
|
if (access === "write") Object.assign(properties, { allowRepack: {
|
|
@@ -77,7 +88,12 @@ const envelopeProperties = (tool) => {
|
|
|
77
88
|
} });
|
|
78
89
|
return properties;
|
|
79
90
|
};
|
|
80
|
-
/**
|
|
91
|
+
/** Arguments only the MCP surface takes, beside the registry's. */
|
|
92
|
+
const MCP_ONLY_PROPERTIES = { read_document: { formatting: {
|
|
93
|
+
type: "boolean",
|
|
94
|
+
description: "Return each block's fields (kind, displayLabel, headingLevel, listLevel, blockTextHash, blockIdSource, tableCell) instead of `[id] text` lines."
|
|
95
|
+
} } };
|
|
96
|
+
/** The tool's full MCP input schema: its own arguments plus the file envelope. */
|
|
81
97
|
const mcpInputSchema = (tool) => {
|
|
82
98
|
const envelope = envelopeProperties(tool);
|
|
83
99
|
for (const key of Object.keys(envelope)) if (key in tool.argsSchema.properties) panic(`${tool.name} declares ${key}, which the file envelope also uses`);
|
|
@@ -86,7 +102,8 @@ const mcpInputSchema = (tool) => {
|
|
|
86
102
|
type: "object",
|
|
87
103
|
properties: {
|
|
88
104
|
...envelope,
|
|
89
|
-
...tool.argsSchema.properties
|
|
105
|
+
...tool.argsSchema.properties,
|
|
106
|
+
...MCP_ONLY_PROPERTIES[tool.name]
|
|
90
107
|
},
|
|
91
108
|
required: [
|
|
92
109
|
"path",
|
|
@@ -96,39 +113,127 @@ const mcpInputSchema = (tool) => {
|
|
|
96
113
|
additionalProperties: false
|
|
97
114
|
};
|
|
98
115
|
};
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
/** Switches that widen what a write may do: they count only as JSON `true`. */
|
|
117
|
+
const EXACT_PROPERTIES = ["overwrite", "allowRepack"];
|
|
118
|
+
const STRING = { type: "string" };
|
|
119
|
+
const BOOLEAN = { type: "boolean" };
|
|
120
|
+
/**
|
|
121
|
+
* The tools listed on every turn, with the schema they are listed with. Each
|
|
122
|
+
* accepts its full schema too; `describe_capability` shows it.
|
|
123
|
+
*/
|
|
124
|
+
const DIRECT_TOOLS = {
|
|
125
|
+
read_document: {
|
|
126
|
+
summary: "Read blocks as `[id] text` lines (a table row as `| [id] cell | [id] cell |`), with the fileVersion writes need. When `nextCursor` is set, pass it as `cursor` for the next page.",
|
|
127
|
+
properties: {
|
|
128
|
+
path: STRING,
|
|
129
|
+
cursor: STRING,
|
|
130
|
+
maxBlocks: { type: "integer" },
|
|
131
|
+
formatting: BOOLEAN
|
|
132
|
+
},
|
|
133
|
+
required: ["path"]
|
|
134
|
+
},
|
|
135
|
+
find_text: {
|
|
136
|
+
summary: "Find text. Each match has its blockId, a `range` to pass to replaceRange or commentOnRange, and context.",
|
|
137
|
+
properties: {
|
|
138
|
+
path: STRING,
|
|
139
|
+
query: STRING,
|
|
140
|
+
matchCase: BOOLEAN,
|
|
141
|
+
wholeWord: BOOLEAN
|
|
142
|
+
},
|
|
143
|
+
required: ["path", "query"]
|
|
144
|
+
},
|
|
145
|
+
suggest_changes: {
|
|
146
|
+
summary: "Apply edits as tracked changes in one batch that lands whole or not at all; returns the new fileVersion. Operations: replaceAll {find, replace, matchCase?, wholeWord?} (every match); replaceInBlock {blockId, find, replace}; replaceRange {range, replace}; replaceBlock {blockId, text}; insertAfterBlock / insertBeforeBlock {blockId, text}; deleteBlock {blockId}; addComment {comment, quote or blockId}; commentOnRange {range, comment}. Others: describe_capability.",
|
|
147
|
+
properties: {
|
|
148
|
+
path: STRING,
|
|
149
|
+
fileVersion: STRING,
|
|
150
|
+
operations: {
|
|
151
|
+
type: "array",
|
|
152
|
+
items: {
|
|
153
|
+
type: "object",
|
|
154
|
+
properties: { type: STRING },
|
|
155
|
+
required: ["type"]
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
required: [
|
|
160
|
+
"path",
|
|
161
|
+
"fileVersion",
|
|
162
|
+
"operations"
|
|
163
|
+
]
|
|
164
|
+
},
|
|
165
|
+
add_comment: {
|
|
166
|
+
summary: "Comment on a block; `quote` anchors it to exact text in the block. Returns the new fileVersion.",
|
|
167
|
+
properties: {
|
|
168
|
+
path: STRING,
|
|
169
|
+
fileVersion: STRING,
|
|
170
|
+
blockId: STRING,
|
|
171
|
+
text: STRING,
|
|
172
|
+
quote: STRING
|
|
173
|
+
},
|
|
174
|
+
required: [
|
|
175
|
+
"path",
|
|
176
|
+
"fileVersion",
|
|
177
|
+
"blockId",
|
|
178
|
+
"text"
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
read_changes: {
|
|
182
|
+
summary: "List pending tracked changes.",
|
|
183
|
+
properties: { path: STRING },
|
|
184
|
+
required: ["path"]
|
|
185
|
+
},
|
|
186
|
+
read_comments: {
|
|
187
|
+
summary: "List comment threads.",
|
|
188
|
+
properties: {
|
|
189
|
+
path: STRING,
|
|
190
|
+
filter: {
|
|
191
|
+
type: "string",
|
|
192
|
+
enum: [
|
|
193
|
+
"all",
|
|
194
|
+
"open",
|
|
195
|
+
"resolved"
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
required: ["path"]
|
|
200
|
+
}
|
|
110
201
|
};
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
202
|
+
/** One-line summaries for the tools reached through the capability tools. */
|
|
203
|
+
const LAZY_SUMMARIES = {
|
|
204
|
+
get_document_outline: {
|
|
205
|
+
summary: "Heading outline with section handles.",
|
|
206
|
+
domain: "read"
|
|
207
|
+
},
|
|
208
|
+
read_section: {
|
|
209
|
+
summary: "Read one section by its outline handle.",
|
|
210
|
+
domain: "read"
|
|
211
|
+
},
|
|
212
|
+
list_stories: {
|
|
213
|
+
summary: "List header, footer and note stories.",
|
|
214
|
+
domain: "read"
|
|
215
|
+
},
|
|
216
|
+
read_story: {
|
|
217
|
+
summary: "Read one story by its handle.",
|
|
218
|
+
domain: "read"
|
|
219
|
+
},
|
|
220
|
+
reply_comment: {
|
|
221
|
+
summary: "Reply to a comment thread.",
|
|
222
|
+
domain: "comments"
|
|
223
|
+
},
|
|
224
|
+
resolve_comment: {
|
|
225
|
+
summary: "Resolve or reopen a comment thread.",
|
|
226
|
+
domain: "comments"
|
|
227
|
+
},
|
|
228
|
+
resolve_changes: {
|
|
229
|
+
summary: "Accept or reject tracked changes.",
|
|
230
|
+
domain: "changes"
|
|
231
|
+
},
|
|
232
|
+
compare_documents: {
|
|
233
|
+
summary: "Diff two .docx files, or write their redline to a destination.",
|
|
234
|
+
domain: "compare"
|
|
125
235
|
}
|
|
126
|
-
}
|
|
127
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
128
|
-
const invalidInput = (message) => cliError({
|
|
129
|
-
code: FOLIO_CLI_ERROR_CODES.invalidInput,
|
|
130
|
-
message
|
|
131
|
-
});
|
|
236
|
+
};
|
|
132
237
|
const ENVELOPE_KEYS = /* @__PURE__ */ new Set([
|
|
133
238
|
"path",
|
|
134
239
|
"fileVersion",
|
|
@@ -137,13 +242,17 @@ const ENVELOPE_KEYS = /* @__PURE__ */ new Set([
|
|
|
137
242
|
"expectedDestinationVersion",
|
|
138
243
|
"txId",
|
|
139
244
|
"allowRepack",
|
|
140
|
-
"mode"
|
|
245
|
+
"mode",
|
|
246
|
+
"formatting"
|
|
141
247
|
]);
|
|
248
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
249
|
+
const invalidInput = (message) => cliError({
|
|
250
|
+
code: FOLIO_CLI_ERROR_CODES.invalidInput,
|
|
251
|
+
message
|
|
252
|
+
});
|
|
142
253
|
const optionalString = (value, name) => value === void 0 || typeof value === "string" ? Result.ok(value) : Result.err(invalidInput(`${name} must be a string.`));
|
|
143
254
|
const toRevisionDate = (date) => date.toISOString().replace(/\.\d{3}Z$/u, "Z");
|
|
144
|
-
const runTool = async (tool,
|
|
145
|
-
if (rawArgs !== void 0 && !isRecord(rawArgs)) return Result.err(invalidInput("Tool arguments must be an object."));
|
|
146
|
-
const input = rawArgs ?? {};
|
|
255
|
+
const runTool = async (tool, input, context) => {
|
|
147
256
|
const args = {};
|
|
148
257
|
for (const [key, value] of Object.entries(input)) if (!ENVELOPE_KEYS.has(key)) args[key] = value;
|
|
149
258
|
const rawPath = input["path"];
|
|
@@ -212,24 +321,340 @@ const runTool = async (tool, rawArgs, context) => {
|
|
|
212
321
|
mode: input["mode"] === "direct" ? "direct" : "tracked-changes"
|
|
213
322
|
});
|
|
214
323
|
};
|
|
215
|
-
const
|
|
216
|
-
|
|
324
|
+
const arrayOf = (value) => Array.isArray(value) ? value : [];
|
|
325
|
+
const recordOf = (value) => isRecord(value) ? value : {};
|
|
326
|
+
/**
|
|
327
|
+
* A number or bullet worth showing. An unnumbered heading's label is its
|
|
328
|
+
* style id (`Heading1`, `Title`), which says nothing the `(hN)` mark does
|
|
329
|
+
* not; every other label (`iii.`, `Article 1`, `•`) is shown.
|
|
330
|
+
*/
|
|
331
|
+
const isNumberLabel = (label, heading) => typeof label === "string" && !(heading && /^\p{L}{3,}\d*$/u.test(label));
|
|
332
|
+
/** One block as the line a model reads: `[id] (h2) 2.1 text`. */
|
|
333
|
+
const blockLine = (block) => {
|
|
334
|
+
const row = recordOf(block);
|
|
335
|
+
const label = row["displayLabel"];
|
|
336
|
+
const isHeading = typeof row["headingLevel"] === "number";
|
|
337
|
+
const heading = isHeading ? `(h${String(row["headingLevel"])}) ` : "";
|
|
338
|
+
const number = isNumberLabel(label, isHeading) ? `${label} ` : "";
|
|
339
|
+
return `[${String(row["blockId"])}] ${heading}${number}${String(row["text"] ?? "")}`;
|
|
340
|
+
};
|
|
341
|
+
/**
|
|
342
|
+
* Blocks as lines, a table row on one line: `| [id] cell | [id] cell |`,
|
|
343
|
+
* a cell's paragraphs side by side. Empty cells hold no block and show none.
|
|
344
|
+
*/
|
|
345
|
+
const blockLines = (blocks) => {
|
|
346
|
+
const lines = [];
|
|
347
|
+
let row;
|
|
348
|
+
const flush = () => {
|
|
349
|
+
if (row !== void 0) lines.push(`| ${row.cells.map((cell) => cell.join(" ")).join(" | ")} |`);
|
|
350
|
+
row = void 0;
|
|
351
|
+
};
|
|
352
|
+
for (const block of blocks) {
|
|
353
|
+
const location = recordOf(recordOf(block)["tableCell"]);
|
|
354
|
+
if (typeof location["table"] !== "number") {
|
|
355
|
+
flush();
|
|
356
|
+
lines.push(blockLine(block));
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const key = `${location["table"]}:${String(location["row"])}`;
|
|
360
|
+
if (row?.key !== key) {
|
|
361
|
+
flush();
|
|
362
|
+
row = {
|
|
363
|
+
key,
|
|
364
|
+
cells: [],
|
|
365
|
+
cell: void 0
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (row.cell !== location["cell"] || row.cells.length === 0) {
|
|
369
|
+
row.cells.push([]);
|
|
370
|
+
row.cell = location["cell"];
|
|
371
|
+
}
|
|
372
|
+
row.cells.at(-1)?.push(blockLine(block));
|
|
373
|
+
}
|
|
374
|
+
flush();
|
|
375
|
+
return lines.join("\n");
|
|
376
|
+
};
|
|
377
|
+
/** A main-story range without the fields the server fills back in. */
|
|
378
|
+
const compactRange = (range) => {
|
|
379
|
+
const { type, story, ...rest } = recordOf(range);
|
|
380
|
+
return type === "textRange" && story === "main" ? rest : range;
|
|
381
|
+
};
|
|
382
|
+
const compactMatch = (match) => {
|
|
383
|
+
const row = recordOf(match);
|
|
384
|
+
if (isRecord(row["range"])) return {
|
|
385
|
+
range: compactRange(row["range"]),
|
|
386
|
+
context: row["context"]
|
|
387
|
+
};
|
|
388
|
+
const { blockTextHash: _hash, type: _type, ...rest } = row;
|
|
389
|
+
return rest;
|
|
390
|
+
};
|
|
391
|
+
const CHANGE_LINE_KEYS = /* @__PURE__ */ new Set([
|
|
392
|
+
"id",
|
|
393
|
+
"type",
|
|
394
|
+
"blockId",
|
|
395
|
+
"text",
|
|
396
|
+
"author",
|
|
397
|
+
"date"
|
|
398
|
+
]);
|
|
399
|
+
/** One tracked change as a line: `12 insertion [blockId] "text" author`, then any other fields. */
|
|
400
|
+
const changeLine = (change) => {
|
|
401
|
+
const row = recordOf(change);
|
|
402
|
+
const extra = Object.entries(row).filter(([key]) => !CHANGE_LINE_KEYS.has(key)).map(([key, value]) => `${key}=${JSON.stringify(value)}`);
|
|
403
|
+
return [
|
|
404
|
+
String(row["id"]),
|
|
405
|
+
String(row["type"]),
|
|
406
|
+
...typeof row["blockId"] === "string" ? [`[${row["blockId"]}]`] : [],
|
|
407
|
+
JSON.stringify(row["text"] ?? ""),
|
|
408
|
+
...typeof row["author"] === "string" ? [row["author"]] : [],
|
|
409
|
+
...extra
|
|
410
|
+
].join(" ");
|
|
411
|
+
};
|
|
412
|
+
const commentIdsOf = (receipts) => arrayOf(receipts).flatMap((receipt) => arrayOf(recordOf(receipt)["affected"]).flatMap((affected) => {
|
|
413
|
+
const entry = recordOf(affected);
|
|
414
|
+
return entry["type"] === "comment" ? [String(entry["commentId"])] : [];
|
|
415
|
+
}));
|
|
416
|
+
/** A read's result, cut to what the next call needs. */
|
|
417
|
+
const compactRead = ({ tool, input }, data) => {
|
|
418
|
+
const { fileVersion } = data;
|
|
419
|
+
const result = data["result"];
|
|
420
|
+
switch (tool.name) {
|
|
421
|
+
case "read_document": {
|
|
422
|
+
const page = recordOf(result);
|
|
423
|
+
const blocks = arrayOf(page["blocks"]);
|
|
424
|
+
return {
|
|
425
|
+
fileVersion,
|
|
426
|
+
blocks: input["formatting"] === true ? blocks : blockLines(blocks),
|
|
427
|
+
...page["nextCursor"] !== void 0 && {
|
|
428
|
+
nextCursor: page["nextCursor"],
|
|
429
|
+
totalBlocks: page["totalBlocks"]
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
case "find_text": {
|
|
434
|
+
const found = recordOf(result);
|
|
435
|
+
return {
|
|
436
|
+
fileVersion,
|
|
437
|
+
matches: arrayOf(found["matches"]).map(compactMatch),
|
|
438
|
+
...found["truncated"] === true && {
|
|
439
|
+
truncated: true,
|
|
440
|
+
totalMatches: found["totalMatches"]
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
case "read_changes": return {
|
|
445
|
+
fileVersion,
|
|
446
|
+
changes: arrayOf(result).map(changeLine).join("\n")
|
|
447
|
+
};
|
|
448
|
+
case "compare_documents": return {
|
|
449
|
+
fileVersion,
|
|
450
|
+
revised: data["revised"],
|
|
451
|
+
diff: recordOf(result)["text"]
|
|
452
|
+
};
|
|
453
|
+
default: return isRecord(result) ? {
|
|
454
|
+
fileVersion,
|
|
455
|
+
...result
|
|
456
|
+
} : {
|
|
457
|
+
fileVersion,
|
|
458
|
+
result
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
/** A write's receipt, cut to the new version and what the change produced. */
|
|
463
|
+
const compactWrite = ({ tool, input }, receipt) => {
|
|
464
|
+
const result = recordOf(receipt["result"]);
|
|
465
|
+
const base = {
|
|
466
|
+
fileVersion: receipt["fileVersion"],
|
|
467
|
+
author: receipt["author"],
|
|
468
|
+
...input["destination"] !== void 0 && { path: receipt["path"] },
|
|
469
|
+
...receipt["status"] === "replayed" && { replayed: true },
|
|
470
|
+
...receipt["rebased"] !== void 0 && { rebased: receipt["rebased"] }
|
|
471
|
+
};
|
|
472
|
+
switch (tool.name) {
|
|
473
|
+
case "suggest_changes": {
|
|
474
|
+
const normalizations = arrayOf(result["normalizations"]);
|
|
475
|
+
const replaced = arrayOf(result["replaced"]);
|
|
476
|
+
const commentIds = commentIdsOf(result["receipts"]);
|
|
477
|
+
return {
|
|
478
|
+
...base,
|
|
479
|
+
applied: arrayOf(result["applied"]).length,
|
|
480
|
+
...replaced.length > 0 && { replaced },
|
|
481
|
+
...commentIds.length > 0 && { commentIds },
|
|
482
|
+
...normalizations.length > 0 && { normalizations }
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
case "add_comment":
|
|
486
|
+
case "reply_comment": return {
|
|
487
|
+
...base,
|
|
488
|
+
commentId: commentIdsOf(result["receipts"]).at(0)
|
|
489
|
+
};
|
|
490
|
+
case "resolve_changes": return {
|
|
491
|
+
...base,
|
|
492
|
+
resolved: result["resolved"],
|
|
493
|
+
remaining: result["remaining"]
|
|
494
|
+
};
|
|
495
|
+
case "compare_documents": return {
|
|
496
|
+
...base,
|
|
497
|
+
...result
|
|
498
|
+
};
|
|
499
|
+
default: return base;
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
const RETRYABLE_CODES = /* @__PURE__ */ new Set([
|
|
503
|
+
FOLIO_CLI_ERROR_CODES.staleVersion,
|
|
504
|
+
FOLIO_CLI_ERROR_CODES.staleTarget,
|
|
505
|
+
FOLIO_CLI_ERROR_CODES.locked
|
|
506
|
+
]);
|
|
507
|
+
/** Refusal details, without the parts that only restate the message. */
|
|
508
|
+
const compactDetails = (details) => {
|
|
509
|
+
if (!isRecord(details) || !Array.isArray(details["skipped"])) return details;
|
|
510
|
+
return { skipped: details["skipped"] };
|
|
511
|
+
};
|
|
512
|
+
const toOutcome = (options, result) => {
|
|
513
|
+
if (result.isErr()) {
|
|
514
|
+
const { code, message, hint, details } = result.error;
|
|
515
|
+
return failure({
|
|
516
|
+
code,
|
|
517
|
+
message,
|
|
518
|
+
hint,
|
|
519
|
+
retryable: RETRYABLE_CODES.has(code),
|
|
520
|
+
details: compactDetails(details)
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const data = recordOf(result.value);
|
|
524
|
+
return success("txId" in data ? compactWrite(options, data) : compactRead(options, data));
|
|
525
|
+
};
|
|
526
|
+
/** Fill in a main-story range's `type` and `story`, which find_text leaves out. */
|
|
527
|
+
const withFullRanges = (operations) => Array.isArray(operations) ? operations.map((operation) => {
|
|
528
|
+
if (!isRecord(operation) || !isRecord(operation["range"])) return operation;
|
|
217
529
|
return {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
530
|
+
...operation,
|
|
531
|
+
range: {
|
|
532
|
+
type: "textRange",
|
|
533
|
+
story: "main",
|
|
534
|
+
...operation["range"]
|
|
535
|
+
}
|
|
223
536
|
};
|
|
537
|
+
}) : operations;
|
|
538
|
+
/** Operation keys the model never writes. */
|
|
539
|
+
const HIDDEN_OPERATION_KEYS = /* @__PURE__ */ new Set(["type", "suggestionId"]);
|
|
540
|
+
/**
|
|
541
|
+
* The fields each operation type accepts, one line per type. The described
|
|
542
|
+
* schema lists every field once for all types, without their descriptions.
|
|
543
|
+
*/
|
|
544
|
+
const OPERATION_FIELDS = [
|
|
545
|
+
"replaceAll: id, find, replace, matchCase, wholeWord, severity, area",
|
|
546
|
+
"addComment: id, comment, blockId, quote, severity, area",
|
|
547
|
+
...SUGGEST_CHANGES_OPERATION_TYPES.map((type) => `${type}: ${FOLIO_DOCUMENT_OPERATION_KEYS_BY_TYPE[type].filter((key) => !HIDDEN_OPERATION_KEYS.has(key)).join(", ")}`)
|
|
548
|
+
].join("\n");
|
|
549
|
+
/** What the compact `describe_capability` says about suggest_changes. */
|
|
550
|
+
const SUGGEST_CHANGES_BRIEF = [
|
|
551
|
+
"Fields (* required):",
|
|
552
|
+
"replaceAll {find*, replace*, matchCase, wholeWord}: every match in the body and tables; run formatting is kept",
|
|
553
|
+
"replaceInBlock {blockId*, find*, replace*}: find must occur once in the block",
|
|
554
|
+
"replaceRange {range*, replace*}: range from find_text",
|
|
555
|
+
"replaceBlock {blockId*, text*, preserveFormatting}",
|
|
556
|
+
"insertAfterBlock | insertBeforeBlock {blockId*, text*, styleId}",
|
|
557
|
+
"deleteBlock {blockId*}",
|
|
558
|
+
"addComment {comment*, blockId | quote}: a quote alone anchors to the one block containing it",
|
|
559
|
+
"commentOnRange {range*, comment*}",
|
|
560
|
+
"formatRange {range*, formatting* {bold, italic, underline}}",
|
|
561
|
+
"Edits take an optional comment. mode \"direct\" edits without tracking. Split, merge and table operations: detail \"full\"."
|
|
562
|
+
].join("\n");
|
|
563
|
+
const SUGGEST_CHANGES_EXAMPLE = {
|
|
564
|
+
path: "contract.docx",
|
|
565
|
+
fileVersion: "<from read_document>",
|
|
566
|
+
operations: [{
|
|
567
|
+
type: "replaceAll",
|
|
568
|
+
find: "Supplier",
|
|
569
|
+
replace: "Provider",
|
|
570
|
+
matchCase: true,
|
|
571
|
+
wholeWord: true
|
|
572
|
+
}, {
|
|
573
|
+
type: "addComment",
|
|
574
|
+
quote: "total liability",
|
|
575
|
+
comment: "Is this cap acceptable?"
|
|
576
|
+
}]
|
|
577
|
+
};
|
|
578
|
+
const guideOf = (tool) => tool.name === "suggest_changes" ? `${describeSuggestChangesCapabilities()}\nFields by operation type:\n${OPERATION_FIELDS}` : tool.description;
|
|
579
|
+
const toKitTool = (tool) => {
|
|
580
|
+
const direct = DIRECT_TOOLS[tool.name];
|
|
581
|
+
const lazy = LAZY_SUMMARIES[tool.name];
|
|
582
|
+
const summary = direct?.summary ?? lazy?.summary ?? panic(`${tool.name} has no MCP summary`);
|
|
583
|
+
const access = toolAccess(tool);
|
|
584
|
+
return {
|
|
585
|
+
name: tool.name,
|
|
586
|
+
summary,
|
|
587
|
+
guide: guideOf(tool),
|
|
588
|
+
...tool.name === "suggest_changes" && {
|
|
589
|
+
brief: SUGGEST_CHANGES_BRIEF,
|
|
590
|
+
example: SUGGEST_CHANGES_EXAMPLE
|
|
591
|
+
},
|
|
592
|
+
access: access === "read" ? "read" : "write",
|
|
593
|
+
destructive: access !== "read",
|
|
594
|
+
...lazy !== void 0 && { domain: lazy.domain },
|
|
595
|
+
inputSchema: mcpInputSchema(tool),
|
|
596
|
+
describedSchema: hoistRepeatedSchemas(compactSchema(mcpInputSchema(tool), { describedDepth: 1 })),
|
|
597
|
+
...direct !== void 0 && { direct: { inputSchema: {
|
|
598
|
+
type: "object",
|
|
599
|
+
properties: direct.properties,
|
|
600
|
+
required: [...direct.required]
|
|
601
|
+
} } },
|
|
602
|
+
exactProperties: EXACT_PROPERTIES,
|
|
603
|
+
run: async (args, context) => {
|
|
604
|
+
const input = tool.name === "suggest_changes" ? {
|
|
605
|
+
...args,
|
|
606
|
+
operations: withFullRanges(args["operations"])
|
|
607
|
+
} : args;
|
|
608
|
+
const result = await Result.tryPromise({
|
|
609
|
+
try: () => runTool(tool, input, context),
|
|
610
|
+
catch: (error) => cliError({
|
|
611
|
+
code: FOLIO_CLI_ERROR_CODES.internal,
|
|
612
|
+
message: error instanceof Error ? error.message : String(error)
|
|
613
|
+
})
|
|
614
|
+
});
|
|
615
|
+
return toOutcome({
|
|
616
|
+
tool,
|
|
617
|
+
input
|
|
618
|
+
}, result.isOk() ? result.value : result);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
};
|
|
622
|
+
const surface = createToolSurface({ tools: FOLIO_FILE_TOOLS.map(toKitTool) });
|
|
623
|
+
const isJsonValue = (value) => {
|
|
624
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
625
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
626
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
627
|
+
return typeof value === "object" && Object.values(value).every(isJsonValue);
|
|
224
628
|
};
|
|
629
|
+
/** The schemas are JSON by construction; a value that is not is a registry bug. */
|
|
630
|
+
const toJsonObject = (properties) => {
|
|
631
|
+
const object = {};
|
|
632
|
+
for (const [key, value] of Object.entries(properties)) object[key] = isJsonValue(value) ? value : panic(`Schema property ${key} is not JSON`);
|
|
633
|
+
return object;
|
|
634
|
+
};
|
|
635
|
+
const toMcpInputSchema = (schema) => {
|
|
636
|
+
const required = arrayOf(schema["required"]).filter((key) => typeof key === "string");
|
|
637
|
+
return {
|
|
638
|
+
type: "object",
|
|
639
|
+
properties: toJsonObject(recordOf(schema["properties"])),
|
|
640
|
+
...required.length > 0 && { required }
|
|
641
|
+
};
|
|
642
|
+
};
|
|
643
|
+
/** What `tools/list` returns. */
|
|
644
|
+
const listMcpTools = () => surface.listTools().map(({ name, description, inputSchema, annotations }) => ({
|
|
645
|
+
name,
|
|
646
|
+
description,
|
|
647
|
+
inputSchema: toMcpInputSchema(inputSchema),
|
|
648
|
+
annotations
|
|
649
|
+
}));
|
|
225
650
|
const aboutText = (roots) => [
|
|
226
651
|
`# folio ${version}`,
|
|
227
652
|
"",
|
|
228
653
|
`Allowed roots: ${roots.join(", ")}`,
|
|
229
654
|
"",
|
|
230
655
|
"- fileVersion is the SHA-256 of the file's bytes; changes require the one you read.",
|
|
231
|
-
"- Block ids are the paragraph's w14:paraId
|
|
232
|
-
"
|
|
656
|
+
"- Block ids are the paragraph's w14:paraId. A paragraph without one is given one derived from",
|
|
657
|
+
" the file's bytes, which the first change writes into the file, so ids survive edits.",
|
|
233
658
|
"- Offsets in range handles are UTF-16 code units into the block text read_document returns.",
|
|
234
659
|
"- A change writes atomically in place or to destination (a plain .docx, never a dotfile or inside",
|
|
235
660
|
" .folio); replacing an existing file needs overwrite plus expectedDestinationVersion. Whatever is",
|
|
@@ -266,21 +691,10 @@ const createFolioMcpServer = (options) => {
|
|
|
266
691
|
tools: {},
|
|
267
692
|
resources: {}
|
|
268
693
|
},
|
|
269
|
-
instructions:
|
|
694
|
+
instructions: instructionsFor(options.author)
|
|
270
695
|
});
|
|
271
696
|
server.setRequestHandler("tools/list", () => ({ tools: listMcpTools() }));
|
|
272
|
-
server.setRequestHandler("tools/call",
|
|
273
|
-
const tool = findFileTool(request.params.name);
|
|
274
|
-
if (tool === void 0) return toCallToolResult(Result.err(invalidInput(`Unknown tool ${JSON.stringify(request.params.name)}.`)));
|
|
275
|
-
const result = await Result.tryPromise({
|
|
276
|
-
try: () => runTool(tool, request.params.arguments, context),
|
|
277
|
-
catch: (error) => cliError({
|
|
278
|
-
code: FOLIO_CLI_ERROR_CODES.internal,
|
|
279
|
-
message: error instanceof Error ? error.message : String(error)
|
|
280
|
-
})
|
|
281
|
-
});
|
|
282
|
-
return toCallToolResult(result.isOk() ? result.value : result);
|
|
283
|
-
});
|
|
697
|
+
server.setRequestHandler("tools/call", (request) => surface.callTool(request.params.name, request.params.arguments, context));
|
|
284
698
|
server.setRequestHandler("resources/list", () => ({ resources: [{
|
|
285
699
|
uri: ABOUT_URI,
|
|
286
700
|
name: "about",
|
|
@@ -307,4 +721,4 @@ const serveFolioMcp = async ({ input, output, ...options }) => {
|
|
|
307
721
|
await closed;
|
|
308
722
|
};
|
|
309
723
|
//#endregion
|
|
310
|
-
export { MCP_READ_BOUNDS, createFolioMcpServer, listMcpTools, mcpInputSchema, serveFolioMcp };
|
|
724
|
+
export { MCP_READ_BOUNDS, blockLines, createFolioMcpServer, listMcpTools, mcpInputSchema, serveFolioMcp };
|
package/dist/package.js
CHANGED