document-mcp 0.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js ADDED
@@ -0,0 +1,831 @@
1
+ #!/usr/bin/env node
2
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
3
+ import { McpServer } from "@modelcontextprotocol/server";
4
+ import { DocumentFormatSchema, OdbReportNotSpecifiedError, OdmUnresolvedSectionError, UnrecognizedDocumentSchemaError, base64ToBytes, buildDocumentBytes, bytesToBase64, createLocalDocumentConverter, decodePackage, documentFromJson, evaluateSelect, extractSourceFontsForFormat, layoutDocumentWithSchema, odbReportToDocx, odbReportToOdt, odbReportToPdf, odbToCsv, odbToXlsx, odmToPdf, parseSelect, readDocumentMetadata, readDocxExtras, readOdbForms, readOdbInventory, readOdbReportContent, readOdbReports, readOdbTables, readPdf, setDocumentMetadata } from "documents.js";
5
+ import { z } from "zod";
6
+ import { readFile, writeFile } from "node:fs/promises";
7
+ import { readFontFace } from "pdf-codec";
8
+ import { decodePackage as decodePackage$1 } from "odf.js";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { basename, join } from "node:path";
11
+ //#region package.json
12
+ var version = "1.1.0";
13
+ //#endregion
14
+ //#region src/io/document-input.ts
15
+ const EXTENSION_TO_FORMAT = {
16
+ docx: "docx",
17
+ pptx: "pptx",
18
+ xlsx: "xlsx",
19
+ odt: "odt",
20
+ odp: "odp",
21
+ ods: "ods",
22
+ odg: "odg",
23
+ odf: "odf",
24
+ markdown: "markdown",
25
+ md: "markdown",
26
+ pdf: "pdf"
27
+ };
28
+ function inferFormatFromExtension(path) {
29
+ const lastSegment = path.split(/[/\\]/).pop() ?? path;
30
+ const dotIndex = lastSegment.lastIndexOf(".");
31
+ if (dotIndex <= 0) return;
32
+ const extension = lastSegment.slice(dotIndex + 1).toLowerCase();
33
+ return EXTENSION_TO_FORMAT[extension];
34
+ }
35
+ /**
36
+ * The hybrid input shape every MCP tool that accepts a document accepts: either a filesystem path (format inferred from its extension) or inline base64-encoded bytes (format required, since there is no filename to infer it from).
37
+ */
38
+ const DocumentInputSchema = z.union([z.object({ path: z.string().describe("Filesystem path to the document to read. The document format is inferred from the file extension.") }), z.object({
39
+ bytesBase64: z.string().describe("Base64-encoded document bytes."),
40
+ format: DocumentFormatSchema.describe("The document format of bytesBase64 -- required, since inline bytes carry no filename to infer it from.")
41
+ })]);
42
+ /**
43
+ * Resolves the hybrid DocumentInput union to concrete bytes and a format: for the path shape, reads the file from disk and infers its format from the extension (throwing if the extension is unrecognised); for the bytesBase64 shape, decodes the inline payload and uses the caller-supplied format directly.
44
+ */
45
+ async function resolveDocumentInput(input, options) {
46
+ if ("path" in input) {
47
+ const format = inferFormatFromExtension(input.path);
48
+ if (format === void 0) throw new Error(`Could not infer a document format from the file extension of "${input.path}". Recognised extensions: ${Object.keys(EXTENSION_TO_FORMAT).join(", ")}. Pass an explicit format via the bytesBase64 input shape instead.`);
49
+ const buffer = await readFile(input.path, { signal: options?.signal });
50
+ return {
51
+ bytes: new Uint8Array(buffer),
52
+ format
53
+ };
54
+ }
55
+ return {
56
+ bytes: base64ToBytes(input.bytesBase64),
57
+ format: input.format
58
+ };
59
+ }
60
+ //#endregion
61
+ //#region src/io/document-output.ts
62
+ /**
63
+ * The hybrid output shape every MCP tool that produces a document accepts: an optional filesystem path to write the result to. Omitting it returns the bytes inline, base64-encoded, instead.
64
+ */
65
+ const DocumentOutputSchema = z.object({ outputPath: z.string().optional().describe("Filesystem path to write the resulting document to. Omit to receive the document bytes inline, base64-encoded, in the tool result instead.") });
66
+ /**
67
+ * Resolves output bytes against the hybrid DocumentOutput shape: writes to `outputPath` and reports the path plus byte length when one is given, otherwise returns the bytes inline as base64 (flagged `large: true` above `LARGE_RESULT_THRESHOLD_BYTES`, never truncated or refused).
68
+ */
69
+ async function resolveDocumentOutput(bytes, output) {
70
+ if (output.outputPath !== void 0) {
71
+ await writeFile(output.outputPath, bytes);
72
+ return {
73
+ path: output.outputPath,
74
+ byteLength: bytes.byteLength
75
+ };
76
+ }
77
+ const bytesBase64 = bytesToBase64(bytes);
78
+ if (bytes.byteLength > 5242880) return {
79
+ bytesBase64,
80
+ byteLength: bytes.byteLength,
81
+ large: true
82
+ };
83
+ return {
84
+ bytesBase64,
85
+ byteLength: bytes.byteLength
86
+ };
87
+ }
88
+ //#endregion
89
+ //#region src/tools/convert.ts
90
+ const DiagnosticSchema = z.object({
91
+ severity: z.enum(["info", "warning"]),
92
+ code: z.string(),
93
+ message: z.string(),
94
+ pageIndex: z.number().optional()
95
+ });
96
+ const FontSubstitutionSchema$1 = z.object({
97
+ requestedFamily: z.string(),
98
+ requestedBold: z.boolean(),
99
+ requestedItalic: z.boolean(),
100
+ reason: z.enum(["missing-face", "vendored-substitute"]),
101
+ resolvedFamily: z.string()
102
+ });
103
+ const ResolvedDocumentOutputSchema = z.union([z.object({
104
+ path: z.string(),
105
+ byteLength: z.number()
106
+ }), z.object({
107
+ bytesBase64: z.string(),
108
+ byteLength: z.number(),
109
+ large: z.literal(true).optional()
110
+ })]);
111
+ const FontInputSchema$1 = z.object({
112
+ family: z.string().describe("The font family name this face provides."),
113
+ bold: z.boolean().describe("Whether this face is the bold weight."),
114
+ italic: z.boolean().describe("Whether this face is the italic slope."),
115
+ bytesBase64: z.string().describe("Base64-encoded font program bytes (TrueType/OpenType/CFF) for this family/weight/style combination.")
116
+ });
117
+ const ConvertDocumentInputSchema = z.object({
118
+ source: DocumentInputSchema.describe("The document to convert."),
119
+ targetFormat: DocumentFormatSchema.describe("The format to convert the document to. Not every (source, targetFormat) pair is supported directly -- call list_document_conversions first to confirm this one is."),
120
+ output: DocumentOutputSchema.optional().describe("Where to write the converted document. Omit entirely to receive the bytes inline, base64-encoded, instead."),
121
+ fonts: z.array(FontInputSchema$1).optional().describe("Extra font faces to make available to the conversion, for a family the source document does not already embed. Only consulted by a conversion that runs a layout engine (a <format>-to-pdf conversion); every other conversion ignores this option entirely."),
122
+ onSubstitutionDiagnostics: z.boolean().optional().describe("When true, additionally report each individual font-substitution event as structured fontSubstitutions (which family/weight/style was requested, what it resolved to instead, and why). Every substitution is always reported as a plain diagnostic in `diagnostics` regardless of this flag -- this only controls whether the fuller, structured event is also collected.")
123
+ });
124
+ const ConvertDocumentOutputSchema = z.object({
125
+ targetFormat: DocumentFormatSchema,
126
+ output: ResolvedDocumentOutputSchema,
127
+ diagnostics: z.array(DiagnosticSchema),
128
+ fontSubstitutions: z.array(FontSubstitutionSchema$1).optional()
129
+ });
130
+ const ListDocumentConversionsOutputSchema = z.object({ conversions: z.array(z.object({
131
+ source: DocumentFormatSchema,
132
+ target: DocumentFormatSchema
133
+ })) });
134
+ function registerConvertTools(server) {
135
+ const converter = createLocalDocumentConverter();
136
+ server.registerTool("convert_document", {
137
+ title: "Convert document",
138
+ description: "Converts a document from one supported format to another via documents.js's DocumentConverter port -- docx, pptx, xlsx, odt, odp, ods, odg, odf, markdown, and pdf. Not every (source, targetFormat) pair is supported directly (odf, for instance, only ever converts to pdf); call list_document_conversions first to see which pairs actually are.",
139
+ inputSchema: ConvertDocumentInputSchema,
140
+ outputSchema: ConvertDocumentOutputSchema
141
+ }, async ({ source, targetFormat, output, fonts, onSubstitutionDiagnostics }, ctx) => {
142
+ const { signal } = ctx.mcpReq;
143
+ const { bytes, format } = await resolveDocumentInput(source, { signal });
144
+ const fontSubstitutions = [];
145
+ const result = await converter.convert({
146
+ source: {
147
+ format,
148
+ bytes
149
+ },
150
+ targetFormat
151
+ }, {
152
+ signal,
153
+ fonts: fonts?.map((font) => ({
154
+ family: font.family,
155
+ bold: font.bold,
156
+ italic: font.italic,
157
+ bytes: base64ToBytes(font.bytesBase64)
158
+ })),
159
+ onFontSubstitution: onSubstitutionDiagnostics === true ? (substitution) => fontSubstitutions.push(substitution) : void 0
160
+ });
161
+ const resolvedOutput = await resolveDocumentOutput(result.document.bytes, output ?? {});
162
+ const structuredContent = {
163
+ targetFormat: result.document.format,
164
+ output: resolvedOutput,
165
+ diagnostics: [...result.diagnostics],
166
+ ...onSubstitutionDiagnostics === true ? { fontSubstitutions: [...fontSubstitutions] } : {}
167
+ };
168
+ return {
169
+ content: [{
170
+ type: "text",
171
+ text: JSON.stringify(structuredContent)
172
+ }],
173
+ structuredContent
174
+ };
175
+ });
176
+ server.registerTool("list_document_conversions", {
177
+ title: "List document conversions",
178
+ description: "Lists every (source, target) format pair convert_document actually supports, straight from documents.js's own DocumentConverter port -- the definitive source of truth for what convert_document will and will not accept as a (source, targetFormat) combination.",
179
+ outputSchema: ListDocumentConversionsOutputSchema
180
+ }, () => {
181
+ const structuredContent = { conversions: converter.conversions.map((conversion) => ({
182
+ source: conversion.source,
183
+ target: conversion.target
184
+ })) };
185
+ return {
186
+ content: [{
187
+ type: "text",
188
+ text: JSON.stringify(structuredContent)
189
+ }],
190
+ structuredContent
191
+ };
192
+ });
193
+ }
194
+ //#endregion
195
+ //#region src/tools/docx-extras.ts
196
+ function registerDocxExtrasTools(server) {
197
+ server.registerTool("docx_extras", {
198
+ title: "Docx extras",
199
+ description: "Reads a docx's own comments, footnotes, headers, footers, and numbering definitions -- data documents.js's ContentDocument pivot cannot carry, so ordinary document-reading tools never see it. Returns the real DocxExtras object (comments/footnotes/headers/footers/numbering) as structured data.",
200
+ inputSchema: z.object({ source: DocumentInputSchema.describe("The docx document to read.") })
201
+ }, async ({ source }) => {
202
+ const { bytes } = await resolveDocumentInput(source);
203
+ const pkg = decodePackage(bytes);
204
+ const extras = readDocxExtras(pkg);
205
+ return {
206
+ content: [{
207
+ type: "text",
208
+ text: JSON.stringify(extras)
209
+ }],
210
+ structuredContent: extras
211
+ };
212
+ });
213
+ }
214
+ //#endregion
215
+ //#region src/tools/fonts.ts
216
+ const FontFileInputSchema = z.union([z.object({ path: z.string().describe("Filesystem path to the font file (.ttf/.otf) to read.") }), z.object({ bytesBase64: z.string().describe("Base64-encoded font file bytes.") })]);
217
+ async function resolveFontFileInput(input) {
218
+ if ("path" in input) {
219
+ const buffer = await readFile(input.path);
220
+ return {
221
+ bytes: new Uint8Array(buffer),
222
+ source: input.path
223
+ };
224
+ }
225
+ return {
226
+ bytes: base64ToBytes(input.bytesBase64),
227
+ source: "inline font bytes"
228
+ };
229
+ }
230
+ /** A tool result reporting a problem: `isError: true` with the message as the sole content block, never a thrown exception -- a thrown error from a tool callback surfaces as a JSON-RPC protocol error rather than a result the caller can inspect and recover from. Matches odb.ts's own errorResult/toErrorResult/jsonResult convention. */
231
+ function errorResult$1(message) {
232
+ return {
233
+ content: [{
234
+ type: "text",
235
+ text: message
236
+ }],
237
+ isError: true
238
+ };
239
+ }
240
+ /** errorResult's counterpart for a caught exception: UnsupportedFontSourceFormatError (extractSourceFontsForFormat rejecting a format with no source-embedded-font concept -- xlsx/pdf/markdown/odf) and FontFaceParseError (readFontFace rejecting bytes that are not a recognised sfnt font, or a .ttc collection) both carry a self-contained, already-actionable message, so there is nothing to add beyond surfacing it verbatim. */
241
+ function toErrorResult$1(error) {
242
+ return errorResult$1(error instanceof Error ? error.message : String(error));
243
+ }
244
+ /** A successful tool result: the value serialised as the text content block, and returned verbatim as structuredContent for a caller that wants the parsed value directly rather than re-parsing the text block. */
245
+ function jsonResult(value) {
246
+ return {
247
+ content: [{
248
+ type: "text",
249
+ text: JSON.stringify(value)
250
+ }],
251
+ structuredContent: value
252
+ };
253
+ }
254
+ function registerFontTools(server) {
255
+ server.registerTool("fonts", {
256
+ title: "List document fonts",
257
+ description: "Lists every source-embedded font face a docx/pptx/odt/odp/ods/odg document carries (family, weight/style, byte length).",
258
+ inputSchema: z.object({ source: DocumentInputSchema.describe("The docx/pptx/odt/odp/ods/odg document to extract source-embedded font faces from.") })
259
+ }, async ({ source }) => {
260
+ try {
261
+ const { bytes, format } = await resolveDocumentInput(source);
262
+ return jsonResult({ faces: extractSourceFontsForFormat(format, bytes).map((face) => ({
263
+ family: face.family,
264
+ bold: face.bold,
265
+ italic: face.italic,
266
+ byteLength: face.bytes.length
267
+ })) });
268
+ } catch (error) {
269
+ return toErrorResult$1(error);
270
+ }
271
+ });
272
+ server.registerTool("describe_font_file", {
273
+ title: "Describe font file",
274
+ description: "Reads a standalone TrueType/OpenType font file (.ttf/.otf) and reports the family/bold/italic triple it declares about itself.",
275
+ inputSchema: z.object({ source: FontFileInputSchema.describe("The standalone .ttf/.otf font file to inspect -- not a document.") })
276
+ }, async ({ source }) => {
277
+ try {
278
+ const { bytes, source: label } = await resolveFontFileInput(source);
279
+ const face = readFontFace(bytes, label);
280
+ return jsonResult({
281
+ family: face.family,
282
+ bold: face.bold,
283
+ italic: face.italic
284
+ });
285
+ } catch (error) {
286
+ return toErrorResult$1(error);
287
+ }
288
+ });
289
+ }
290
+ //#endregion
291
+ //#region src/tools/from-package.ts
292
+ async function readSourceBytes(source) {
293
+ if ("path" in source) {
294
+ const buffer = await readFile(source.path);
295
+ return new Uint8Array(buffer);
296
+ }
297
+ return base64ToBytes(source.bytesBase64);
298
+ }
299
+ function errorResult(message) {
300
+ return {
301
+ content: [{
302
+ type: "text",
303
+ text: message
304
+ }],
305
+ isError: true
306
+ };
307
+ }
308
+ function registerFromPackageTools(server) {
309
+ server.registerTool("from_package", {
310
+ title: "Build document from package",
311
+ description: "Rebuilds real document bytes in a target format from a DocumentPackage previously serialised to JSON (e.g. by a caller's own --dump-package-equivalent step) -- the read side of the DocumentPackage round trip a conversion's onDocument callback produces.",
312
+ inputSchema: z.object({
313
+ source: DocumentInputSchema.describe("The DocumentPackage JSON to read. 'path' points at a JSON file on disk -- its extension is never used to infer a document format, since the file holds a DocumentPackage, not a document. 'bytesBase64' carries the JSON inline; its 'format' field is required by the shared hybrid input shape but unused by this tool."),
314
+ targetFormat: DocumentFormatSchema.describe("The document format to build from the DocumentPackage."),
315
+ output: DocumentOutputSchema.optional().describe("Where to write the resulting document. Omit entirely (or omit outputPath within it) to receive the bytes inline instead.")
316
+ })
317
+ }, async ({ source, targetFormat, output }) => {
318
+ try {
319
+ const bytes = await readSourceBytes(source);
320
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
321
+ let parsed;
322
+ try {
323
+ parsed = JSON.parse(text);
324
+ } catch (error) {
325
+ return errorResult(`'source' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
326
+ }
327
+ const result = documentFromJson(parsed);
328
+ if (result.kind !== "DocumentPackage") return errorResult(`'source' is a ${result.kind}, not a DocumentPackage -- only a file carrying a real DocumentPackage (e.g. written by a caller's own --dump-package-equivalent step) can be read back by this tool`);
329
+ const resolvedOutput = await resolveDocumentOutput(buildDocumentBytes(result.value, targetFormat), output ?? {});
330
+ return {
331
+ content: [{
332
+ type: "text",
333
+ text: JSON.stringify(resolvedOutput)
334
+ }],
335
+ structuredContent: resolvedOutput
336
+ };
337
+ } catch (error) {
338
+ if (error instanceof UnrecognizedDocumentSchemaError) return errorResult("'source' has no recognised $schema -- only a file carrying a real DocumentPackage (e.g. written by a caller's own --dump-package-equivalent step) can be read back by this tool");
339
+ return errorResult(error instanceof Error ? error.message : String(error));
340
+ }
341
+ });
342
+ }
343
+ //#endregion
344
+ //#region src/tools/metadata.ts
345
+ function toErrorResult(error) {
346
+ return {
347
+ content: [{
348
+ type: "text",
349
+ text: error instanceof Error ? error.message : String(error)
350
+ }],
351
+ isError: true
352
+ };
353
+ }
354
+ function registerMetadataTools(server) {
355
+ server.registerTool("metadata_read", {
356
+ title: "Read document metadata",
357
+ description: "Reads a document's own title/author/subject/keywords/creator/producer/created-and-modified-timestamp metadata. Works across every supported format, including xlsx (read via a throwaway xlsx-to-pdf preview, since documents.js has no dedicated xlsx metadata reader of its own) and odf (a standalone formula document).",
358
+ inputSchema: z.object({ source: DocumentInputSchema.describe("The document to read metadata from.") })
359
+ }, async ({ source }) => {
360
+ try {
361
+ const { bytes, format } = await resolveDocumentInput(source);
362
+ const metadata = readDocumentMetadata(format, bytes);
363
+ return {
364
+ content: [{
365
+ type: "text",
366
+ text: JSON.stringify(metadata)
367
+ }],
368
+ structuredContent: metadata
369
+ };
370
+ } catch (error) {
371
+ return toErrorResult(error);
372
+ }
373
+ });
374
+ server.registerTool("metadata_write", {
375
+ title: "Write document metadata",
376
+ description: "Patches a document's own title/author/subject/keywords, leaving every other field and every other flag as-is. Does not convert format -- the source document's own format and targetFormat must match (or both be 'pdf'); xlsx and odf are rejected outright as either a source or a target, in both directions. Convert the document to a different format first (e.g. with a documents.js conversion tool) if metadata needs to be set on the result of a format change.",
377
+ inputSchema: z.object({
378
+ source: DocumentInputSchema.describe("The document to patch metadata on."),
379
+ targetFormat: DocumentFormatSchema.describe("The format to write the patched document back out as -- must match the source document's own format (or both be 'pdf'). metadata_write never converts format."),
380
+ output: DocumentOutputSchema.optional().describe("Where to write the patched document. Omit entirely to receive the bytes inline, base64-encoded."),
381
+ setTitle: z.string().optional().describe("Set the title field. Omit to leave it exactly as the source document already has it."),
382
+ setAuthor: z.string().optional().describe("Set the author field. Omit to leave it exactly as the source document already has it."),
383
+ setSubject: z.string().optional().describe("Set the subject field. Omit to leave it exactly as the source document already has it."),
384
+ setKeywords: z.array(z.string()).optional().describe("Set the keywords field. Omit to leave it exactly as the source document already has it.")
385
+ })
386
+ }, async ({ source, targetFormat, output, setTitle, setAuthor, setSubject, setKeywords }) => {
387
+ try {
388
+ const { bytes, format } = await resolveDocumentInput(source);
389
+ const resolvedOutput = await resolveDocumentOutput(setDocumentMetadata(format, targetFormat, bytes, {
390
+ title: setTitle,
391
+ author: setAuthor,
392
+ subject: setSubject,
393
+ keywords: setKeywords
394
+ }), output ?? {});
395
+ return {
396
+ content: [{
397
+ type: "text",
398
+ text: JSON.stringify(resolvedOutput)
399
+ }],
400
+ structuredContent: resolvedOutput
401
+ };
402
+ } catch (error) {
403
+ return toErrorResult(error);
404
+ }
405
+ });
406
+ }
407
+ //#endregion
408
+ //#region src/tools/odb.ts
409
+ const ODB_SOURCE_DESCRIPTION = ".odb database to read. 'path' points at the .odb file on disk -- its extension is never used to infer a document format, since documents.js deliberately excludes 'odb' from DocumentFormat (an embedded database front end has no single natural target format -- tables, saved queries, and reports are three unrelated output shapes -- see that package's own README). 'bytesBase64' carries the .odb bytes inline; its 'format' field is required by the shared hybrid input shape but unused by every odb tool.";
410
+ async function resolveOdbBytes$1(source) {
411
+ if ("path" in source) {
412
+ const buffer = await readFile(source.path);
413
+ return new Uint8Array(buffer);
414
+ }
415
+ return base64ToBytes(source.bytesBase64);
416
+ }
417
+ /** Resolves an .odb DocumentInput straight through to a decoded odf.js Package -- the shape every read (as opposed to export) odb tool needs. */
418
+ async function resolveOdbPackage(source) {
419
+ return decodePackage$1(await resolveOdbBytes$1(source));
420
+ }
421
+ /** Resolves a saved query's own SQL text by name against the .odb's own db:queries (read via readOdbInventory) -- odb_query's own equivalent of document-cli's resolveQuerySql, for the case where the caller named a saved query rather than supplying SQL directly. Throws, naming every available query, when the name doesn't resolve -- see this module's own top-of-file note on why a thrown Error is the right shape here. */
422
+ function resolveSavedQuerySql(pkg, name) {
423
+ const inventory = readOdbInventory(pkg);
424
+ const saved = inventory.queries.find((candidate) => candidate.name === name);
425
+ if (saved === void 0) {
426
+ const available = inventory.queries.map((candidate) => candidate.name);
427
+ throw new Error(`This .odb declares no saved query named "${name}".${available.length === 0 ? "" : ` Available: ${available.join(", ")}.`}`);
428
+ }
429
+ return saved.command;
430
+ }
431
+ /** Classifies odb_query's own sql/query pair before any package is read: exactly one of the two must be given. Throws for either "both given" or "neither given". Kept as a pure, synchronous check (no Package involved) so both variables narrow cleanly through ordinary control flow, rather than needing a cross-variable inference TypeScript can't derive from two separate `!== undefined` checks. */
432
+ function classifyQueryInput(sql, query) {
433
+ if (sql !== void 0 && query !== void 0) throw new Error("Provide \"sql\" or \"query\", not both.");
434
+ if (sql !== void 0) return {
435
+ kind: "literal",
436
+ sql
437
+ };
438
+ if (query !== void 0) return {
439
+ kind: "saved",
440
+ name: query
441
+ };
442
+ throw new Error("Provide either \"sql\" (a literal SELECT statement) or \"query\" (the name of one of this .odb's own saved queries).");
443
+ }
444
+ function registerOdbTools(server) {
445
+ server.registerTool("odb_tables", {
446
+ title: "List .odb tables",
447
+ description: "Lists every table an embedded .odb database declares -- column names, types, and row data -- across every storage tier documents.js supports (HSQLDB TEXT/CACHED/BINARY script formats, Firebird gbak backups).",
448
+ inputSchema: z.object({ source: DocumentInputSchema.describe(ODB_SOURCE_DESCRIPTION) })
449
+ }, async ({ source }) => {
450
+ const pkg = await resolveOdbPackage(source);
451
+ const tables = readOdbTables(pkg);
452
+ return {
453
+ content: [{
454
+ type: "text",
455
+ text: JSON.stringify(tables)
456
+ }],
457
+ structuredContent: tables
458
+ };
459
+ });
460
+ server.registerTool("odb_forms", {
461
+ title: "List .odb forms",
462
+ description: "Lists every form an .odb database declares, with each form's own data source and field-bound controls.",
463
+ inputSchema: z.object({ source: DocumentInputSchema.describe(ODB_SOURCE_DESCRIPTION) })
464
+ }, async ({ source }) => {
465
+ const pkg = await resolveOdbPackage(source);
466
+ const forms = readOdbForms(pkg);
467
+ return {
468
+ content: [{
469
+ type: "text",
470
+ text: JSON.stringify(forms)
471
+ }],
472
+ structuredContent: forms
473
+ };
474
+ });
475
+ server.registerTool("odb_reports", {
476
+ title: "List .odb reports",
477
+ description: "Lists every report an .odb database declares, with each report's own data-source command, band/group structure, and rpt: formula expressions.",
478
+ inputSchema: z.object({ source: DocumentInputSchema.describe(ODB_SOURCE_DESCRIPTION) })
479
+ }, async ({ source }) => {
480
+ const pkg = await resolveOdbPackage(source);
481
+ const reports = readOdbReports(pkg);
482
+ return {
483
+ content: [{
484
+ type: "text",
485
+ text: JSON.stringify(reports)
486
+ }],
487
+ structuredContent: reports
488
+ };
489
+ });
490
+ server.registerTool("odb_query", {
491
+ title: "Query an .odb database",
492
+ description: "Runs a bounded single-table SELECT over an embedded .odb database's own extracted tables, given directly as SQL or by naming one of the database's saved queries. No database engine is involved -- the query runs in memory over the same tables odb_tables would return, against a closed grammar (SELECT/WHERE/GROUP BY/ORDER BY, no joins or subqueries); an unsupported construct is reported as a tool error naming it, never silently ignored.",
493
+ inputSchema: z.object({
494
+ source: DocumentInputSchema.describe(ODB_SOURCE_DESCRIPTION),
495
+ sql: z.string().describe("A literal SELECT statement to run. Mutually exclusive with \"query\".").optional(),
496
+ query: z.string().describe("The name of one of the .odb's own saved queries to run. Mutually exclusive with \"sql\".").optional()
497
+ })
498
+ }, async ({ source, sql, query }) => {
499
+ const spec = classifyQueryInput(sql, query);
500
+ const pkg = await resolveOdbPackage(source);
501
+ const resolvedSql = spec.kind === "literal" ? spec.sql : resolveSavedQuerySql(pkg, spec.name);
502
+ const result = evaluateSelect(parseSelect(resolvedSql), readOdbTables(pkg));
503
+ return {
504
+ content: [{
505
+ type: "text",
506
+ text: JSON.stringify(result)
507
+ }],
508
+ structuredContent: result
509
+ };
510
+ });
511
+ server.registerTool("odb_to_csv", {
512
+ title: "Export one .odb table to CSV",
513
+ description: "Extracts exactly one named table from an embedded .odb database as CSV bytes. The table name is required whenever the database declares more than one table.",
514
+ inputSchema: z.object({
515
+ source: DocumentInputSchema.describe(ODB_SOURCE_DESCRIPTION),
516
+ table: z.string().describe("The table to export -- required when the .odb declares more than one table.").optional(),
517
+ output: DocumentOutputSchema.optional().describe("Where to write the resulting CSV. Omit entirely (or omit outputPath within it) to receive the bytes inline instead.")
518
+ })
519
+ }, async ({ source, table, output }, ctx) => {
520
+ const bytes = await resolveOdbBytes$1(source);
521
+ const resolvedOutput = await resolveDocumentOutput(odbToCsv(bytes, {
522
+ signal: ctx.mcpReq.signal,
523
+ table
524
+ }), output ?? {});
525
+ return {
526
+ content: [{
527
+ type: "text",
528
+ text: JSON.stringify(resolvedOutput)
529
+ }],
530
+ structuredContent: resolvedOutput
531
+ };
532
+ });
533
+ server.registerTool("odb_to_xlsx", {
534
+ title: "Export .odb tables to xlsx",
535
+ description: "Extracts every table an embedded .odb database declares into one xlsx workbook, one sheet per table.",
536
+ inputSchema: z.object({
537
+ source: DocumentInputSchema.describe(ODB_SOURCE_DESCRIPTION),
538
+ output: DocumentOutputSchema.optional().describe("Where to write the resulting xlsx workbook. Omit entirely (or omit outputPath within it) to receive the bytes inline instead.")
539
+ })
540
+ }, async ({ source, output }, ctx) => {
541
+ const bytes = await resolveOdbBytes$1(source);
542
+ const resolvedOutput = await resolveDocumentOutput(odbToXlsx(bytes, { signal: ctx.mcpReq.signal }), output ?? {});
543
+ return {
544
+ content: [{
545
+ type: "text",
546
+ text: JSON.stringify(resolvedOutput)
547
+ }],
548
+ structuredContent: resolvedOutput
549
+ };
550
+ });
551
+ }
552
+ //#endregion
553
+ //#region src/tools/odb-render-report.ts
554
+ async function resolveOdbBytes(source) {
555
+ if ("path" in source) {
556
+ const buffer = await readFile(source.path);
557
+ return new Uint8Array(buffer);
558
+ }
559
+ return base64ToBytes(source.bytesBase64);
560
+ }
561
+ const FontInputSchema = z.object({
562
+ family: z.string().describe("The font family name this face provides."),
563
+ bold: z.boolean().describe("Whether this face is the bold weight."),
564
+ italic: z.boolean().describe("Whether this face is the italic slope."),
565
+ bytesBase64: z.string().describe("Base64-encoded font program bytes (TrueType/OpenType/CFF) for this family/weight/style combination.")
566
+ });
567
+ const OdbRenderReportTargetFormatSchema = z.enum([
568
+ "docx",
569
+ "odt",
570
+ "pdf"
571
+ ]);
572
+ const OdbRenderReportInputSchema = z.object({
573
+ source: DocumentInputSchema.describe(".odb database to render a report from. 'path' points at the .odb file on disk -- its extension is never used to infer a document format, since documents.js deliberately excludes 'odb' from DocumentFormat (an embedded database front end has no single natural target format -- tables, saved queries, and reports are three unrelated output shapes -- see that package's own README). 'bytesBase64' carries the .odb bytes inline; its 'format' field is required by the shared hybrid input shape but unused by this tool."),
574
+ report: z.string().optional().describe("The name of the report to render. Required only when the .odb declares more than one report -- omitting it when exactly one is declared renders that one automatically."),
575
+ targetFormat: OdbRenderReportTargetFormatSchema.describe("The format to render the report into."),
576
+ output: DocumentOutputSchema.optional().describe("Where to write the rendered report. Omit entirely (or omit outputPath within it) to receive the bytes inline instead."),
577
+ fonts: z.array(FontInputSchema).optional().describe("Extra font faces to make available, for a family the rendered report's own text otherwise falls back on. Only consulted when targetFormat is 'pdf' -- docx and odt output is genuine editable text with no font-embedding step of its own, so fonts is ignored for those two targets.")
578
+ });
579
+ const MathDiagnosticSchema = z.object({
580
+ kind: z.enum(["unsupported-element", "approximated-element"]),
581
+ detail: z.string(),
582
+ sourcePath: z.string().optional()
583
+ });
584
+ const FontSubstitutionSchema = z.object({
585
+ requestedFamily: z.string(),
586
+ requestedBold: z.boolean(),
587
+ requestedItalic: z.boolean(),
588
+ reason: z.enum(["missing-face", "vendored-substitute"]),
589
+ resolvedFamily: z.string()
590
+ });
591
+ const CharSubstitutionSchema = z.object({
592
+ from: z.string(),
593
+ to: z.string(),
594
+ pageIndex: z.number()
595
+ });
596
+ const diagnosticsShape = {
597
+ mathDiagnostics: z.array(MathDiagnosticSchema),
598
+ fontSubstitutions: z.array(FontSubstitutionSchema),
599
+ charSubstitutions: z.array(CharSubstitutionSchema)
600
+ };
601
+ const OdbRenderReportOutputSchema = z.union([z.object({
602
+ path: z.string(),
603
+ byteLength: z.number(),
604
+ ...diagnosticsShape
605
+ }), z.object({
606
+ bytesBase64: z.string(),
607
+ byteLength: z.number(),
608
+ large: z.literal(true).optional(),
609
+ ...diagnosticsShape
610
+ })]);
611
+ function odbReportNotSpecifiedResult(error) {
612
+ return {
613
+ isError: true,
614
+ content: [{
615
+ type: "text",
616
+ text: error.message
617
+ }],
618
+ structuredContent: { availableReports: error.availableReports }
619
+ };
620
+ }
621
+ function registerOdbRenderReportTools(server) {
622
+ server.registerTool("odb_render_report", {
623
+ title: "Render .odb report",
624
+ description: "Resolves one of an .odb database's own reports -- its data-bound command run through the bounded SQL engine, its rpt: formulas evaluated, its bands laid out -- and renders the result to docx, odt, or pdf.",
625
+ inputSchema: OdbRenderReportInputSchema,
626
+ outputSchema: OdbRenderReportOutputSchema
627
+ }, async ({ source, report, targetFormat, output, fonts }, ctx) => {
628
+ const { signal } = ctx.mcpReq;
629
+ const inputBytes = await resolveOdbBytes(source);
630
+ const pkg = decodePackage$1(inputBytes);
631
+ let content;
632
+ try {
633
+ content = readOdbReportContent(pkg, { report });
634
+ } catch (error) {
635
+ if (error instanceof OdbReportNotSpecifiedError) return odbReportNotSpecifiedResult(error);
636
+ throw error;
637
+ }
638
+ const mathDiagnostics = [];
639
+ const recordMathDiagnostic = (diagnostic, diagnosticContext) => {
640
+ mathDiagnostics.push({
641
+ kind: diagnostic.kind,
642
+ detail: diagnostic.detail,
643
+ sourcePath: diagnosticContext.sourcePath
644
+ });
645
+ };
646
+ const fontSubstitutions = [];
647
+ const charSubstitutions = [];
648
+ let bytes;
649
+ if (targetFormat === "docx") bytes = odbReportToDocx(content, {
650
+ signal,
651
+ onMathDiagnostic: recordMathDiagnostic
652
+ });
653
+ else if (targetFormat === "odt") bytes = odbReportToOdt(content, { signal });
654
+ else bytes = odbReportToPdf(content, {
655
+ signal,
656
+ fonts: fonts?.map((font) => ({
657
+ family: font.family,
658
+ bold: font.bold,
659
+ italic: font.italic,
660
+ bytes: base64ToBytes(font.bytesBase64)
661
+ })),
662
+ onFontSubstitution: (substitution) => fontSubstitutions.push(substitution),
663
+ onSubstitution: (substitution, substitutionContext) => {
664
+ charSubstitutions.push({
665
+ from: substitution.from,
666
+ to: substitution.to,
667
+ pageIndex: substitutionContext.pageIndex
668
+ });
669
+ },
670
+ onMathDiagnostic: recordMathDiagnostic
671
+ });
672
+ const structuredContent = {
673
+ ...await resolveDocumentOutput(bytes, output ?? {}),
674
+ mathDiagnostics,
675
+ fontSubstitutions,
676
+ charSubstitutions
677
+ };
678
+ return {
679
+ content: [{
680
+ type: "text",
681
+ text: JSON.stringify(structuredContent)
682
+ }],
683
+ structuredContent
684
+ };
685
+ });
686
+ }
687
+ //#endregion
688
+ //#region src/tools/odm.ts
689
+ const OdmMasterSourceSchema = z.union([z.object({ path: z.string().describe("Filesystem path to the .odm master document to convert.") }), z.object({ bytesBase64: z.string().describe("Base64-encoded .odm master document bytes.") })]);
690
+ async function resolveOdmMasterBytes(source) {
691
+ if ("path" in source) return new Uint8Array(await readFile(source.path));
692
+ return base64ToBytes(source.bytesBase64);
693
+ }
694
+ const OdmChapterInputSchema = z.object({
695
+ href: z.string().describe("The chapter's own text:section-source href as declared inside the .odm master document (e.g. '../chapter1.odt')."),
696
+ source: DocumentInputSchema.describe("The chapter document's own bytes -- always read as odt, regardless of the format this hybrid input declares.")
697
+ });
698
+ const OdmToPdfInputSchema = z.object({
699
+ source: OdmMasterSourceSchema.describe("The .odm master document to convert."),
700
+ chapters: z.array(OdmChapterInputSchema).default([]).describe("Explicit href -> chapter document overrides. Checked before chaptersDir for a given href."),
701
+ chaptersDir: z.string().optional().describe("Directory to search for each unresolved chapter href, matched by the href's own basename. Checked after chapters."),
702
+ output: DocumentOutputSchema.default({}).describe("Where to write the resulting PDF. Omit to receive the bytes inline, base64-encoded.")
703
+ });
704
+ const OdmToPdfOutputSchema = z.union([z.object({
705
+ path: z.string(),
706
+ byteLength: z.number()
707
+ }), z.object({
708
+ bytesBase64: z.string(),
709
+ byteLength: z.number(),
710
+ large: z.literal(true).optional()
711
+ })]);
712
+ function registerOdmTools(server) {
713
+ server.registerTool("odm_to_pdf", {
714
+ title: "Convert ODM master document to PDF",
715
+ description: "Converts a .odm (ODF master document) to PDF. A .odm never carries its own chapters' content inline -- every text:section is a bare external reference to a standalone .odt file -- so each chapter the master document declares must resolve through `chapters` (an explicit href -> document override) and/or `chaptersDir` (a directory searched by the href's own basename), checked in that order. A chapter left unresolved by both fails the whole conversion, naming every unresolved href.",
716
+ inputSchema: OdmToPdfInputSchema,
717
+ outputSchema: OdmToPdfOutputSchema
718
+ }, async ({ source, chapters, chaptersDir, output }) => {
719
+ const masterBytes = await resolveOdmMasterBytes(source);
720
+ const overrides = /* @__PURE__ */ new Map();
721
+ for (const chapter of chapters) {
722
+ const { bytes: chapterBytes } = await resolveDocumentInput(chapter.source);
723
+ overrides.set(chapter.href, chapterBytes);
724
+ }
725
+ const resolveSubDocument = (href) => {
726
+ const overrideBytes = overrides.get(href);
727
+ if (overrideBytes !== void 0) return overrideBytes;
728
+ if (chaptersDir === void 0) return;
729
+ const candidate = join(chaptersDir, basename(href));
730
+ if (!existsSync(candidate)) return;
731
+ return new Uint8Array(readFileSync(candidate));
732
+ };
733
+ try {
734
+ const result = await resolveDocumentOutput(odmToPdf(masterBytes, { resolveSubDocument }), output);
735
+ return {
736
+ content: [{
737
+ type: "text",
738
+ text: JSON.stringify(result)
739
+ }],
740
+ structuredContent: result
741
+ };
742
+ } catch (error) {
743
+ if (error instanceof OdmUnresolvedSectionError) return {
744
+ isError: true,
745
+ content: [{
746
+ type: "text",
747
+ text: `${error.message}\nPass chaptersDir <dir> containing these files, or an explicit chapters override, for each href.`
748
+ }],
749
+ structuredContent: { hrefs: error.hrefs }
750
+ };
751
+ throw error;
752
+ }
753
+ });
754
+ }
755
+ //#endregion
756
+ //#region src/tools/pdf-inspect.ts
757
+ function buildItemKindHistogram(items) {
758
+ const histogram = /* @__PURE__ */ new Map();
759
+ for (const item of items) histogram.set(item.kind, (histogram.get(item.kind) ?? 0) + 1);
760
+ return histogram;
761
+ }
762
+ function countImagesByFormat(images) {
763
+ const counts = /* @__PURE__ */ new Map();
764
+ for (const asset of Object.values(images)) counts.set(asset.format, (counts.get(asset.format) ?? 0) + 1);
765
+ return counts;
766
+ }
767
+ function registerPdfInspectTools(server) {
768
+ server.registerTool("pdf_inspect", {
769
+ title: "Inspect PDF",
770
+ description: "Parses a PDF (documents.js's readPdf) and reports a summary: page count, each page's own size and item-kind histogram, document metadata, and embedded image formats. Pass full: true to return the entire parsed LayoutDocument (tagged with its own $schema) instead of a summary.",
771
+ inputSchema: z.object({
772
+ source: DocumentInputSchema.describe("The PDF document to inspect."),
773
+ full: z.boolean().optional().describe("When true, return the entire parsed LayoutDocument instead of a summary. Defaults to false.")
774
+ })
775
+ }, async ({ source, full }, ctx) => {
776
+ const { signal } = ctx.mcpReq;
777
+ const { bytes, format } = await resolveDocumentInput(source, { signal });
778
+ if (format !== "pdf") throw new Error(`pdf_inspect requires a PDF document, received a "${format}" document instead.`);
779
+ const layout = readPdf(bytes, { signal });
780
+ if (full === true) {
781
+ const tagged = layoutDocumentWithSchema(layout);
782
+ return {
783
+ content: [{
784
+ type: "text",
785
+ text: JSON.stringify(tagged)
786
+ }],
787
+ structuredContent: tagged
788
+ };
789
+ }
790
+ const summary = {
791
+ pageCount: layout.pages.length,
792
+ pages: layout.pages.map((page) => ({
793
+ widthPt: page.widthPt,
794
+ heightPt: page.heightPt,
795
+ itemKinds: Object.fromEntries(buildItemKindHistogram(page.items))
796
+ })),
797
+ metadata: layout.metadata,
798
+ imagesByFormat: Object.fromEntries(countImagesByFormat(layout.images))
799
+ };
800
+ return {
801
+ content: [{
802
+ type: "text",
803
+ text: JSON.stringify(summary)
804
+ }],
805
+ structuredContent: summary
806
+ };
807
+ });
808
+ }
809
+ //#endregion
810
+ //#region src/server.ts
811
+ function createServer() {
812
+ const server = new McpServer({
813
+ name: "document-mcp",
814
+ version
815
+ });
816
+ registerConvertTools(server);
817
+ registerDocxExtrasTools(server);
818
+ registerFontTools(server);
819
+ registerFromPackageTools(server);
820
+ registerMetadataTools(server);
821
+ registerOdbTools(server);
822
+ registerOdbRenderReportTools(server);
823
+ registerOdmTools(server);
824
+ registerPdfInspectTools(server);
825
+ return server;
826
+ }
827
+ //#endregion
828
+ //#region src/bin.ts
829
+ serveStdio(createServer);
830
+ //#endregion
831
+ export {};