@stll/folio-cli 0.3.4 → 0.4.1

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/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 { failureEnvelope, successEnvelope } from "./output.js";
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, findFileTool, toolAccess } from "./registry.js";
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. Every tool takes
16
- * the file envelope (`path`, `fileVersion`, and for writes `destination`,
17
- * `txId`, ...) beside its own arguments, every path must resolve inside an
18
- * allowed root, and results are the same `{ ok, data | error }` envelope the
19
- * command line prints.
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 INSTRUCTIONS = "Tools read and change .docx files on disk. Start with get_document_outline or read_document to get block ids and the file's fileVersion; find_text returns exact range handles. Mutating tools require the fileVersion you read and refuse (stale_version) when the file changed; re-read and retry. Edits are tracked changes unless mode is direct. Without destination a change is written in place with a backup; with destination it goes to that new file. Results are { ok, data } or { ok: false, error: { code, message, hint } }.";
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: "^[0-9a-f]{64}$",
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: "^[0-9a-f]{64}$",
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 receipt."
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
- /** The tool's MCP input schema: its own arguments plus the file envelope. */
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
- const isJsonValue = (value) => {
100
- if (value === null || typeof value === "string" || typeof value === "boolean") return true;
101
- if (typeof value === "number") return Number.isFinite(value);
102
- if (Array.isArray(value)) return value.every(isJsonValue);
103
- return typeof value === "object" && Object.values(value).every(isJsonValue);
104
- };
105
- /** The registry schemas are JSON by construction; a value that is not is a registry bug. */
106
- const toJsonObject = (properties) => {
107
- const object = {};
108
- for (const [key, value] of Object.entries(properties)) object[key] = isJsonValue(value) ? value : panic(`Schema property ${key} is not JSON`);
109
- return object;
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
- const toMcpInputSchema = ({ properties, required }) => ({
112
- type: "object",
113
- properties: toJsonObject(properties),
114
- required: [...required],
115
- additionalProperties: false
116
- });
117
- const listMcpTools = () => FOLIO_FILE_TOOLS.map((tool) => ({
118
- name: tool.name,
119
- description: tool.description,
120
- inputSchema: toMcpInputSchema(mcpInputSchema(tool)),
121
- annotations: {
122
- readOnlyHint: toolAccess(tool) === "read",
123
- destructiveHint: toolAccess(tool) !== "read",
124
- openWorldHint: false
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, rawArgs, context) => {
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 toCallToolResult = (result) => {
216
- const envelope = result.isOk() ? successEnvelope(result.value) : failureEnvelope(result.error);
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
- content: [{
219
- type: "text",
220
- text: JSON.stringify(envelope)
221
- }],
222
- isError: !envelope.ok
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 (blockIdSource: package) or derived from text and",
232
- " position (synthetic), valid only for the fileVersion they were read at.",
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: INSTRUCTIONS
694
+ instructions: instructionsFor(options.author)
270
695
  });
271
696
  server.setRequestHandler("tools/list", () => ({ tools: listMcpTools() }));
272
- server.setRequestHandler("tools/call", async (request) => {
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
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.3.4";
2
+ var version = "0.4.1";
3
3
  //#endregion
4
4
  export { version };