beliq-mcp 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 CHANGED
@@ -1,15 +1,15 @@
1
1
  # beliq-mcp
2
2
 
3
- An [MCP](https://modelcontextprotocol.io) server for [beliq](https://beliq.eu), the EU e-invoicing compliance API. It lets MCP clients (Claude Desktop, Claude Code, Cursor, and others) **validate**, **parse**, **generate**, and **convert** electronic invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS, and other UBL/CII documents) against authority-pinned, drift-checked rules, and explain exactly what fails.
3
+ An [MCP](https://modelcontextprotocol.io) server for [beliq](https://beliq.eu), the EU e-invoicing compliance API. It lets MCP clients (Claude Desktop, Claude Code, Cursor, and others) **validate**, **parse**, **generate**, and **convert** electronic invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS, and the other formats the API carries), and explain exactly what fails. Every verdict states how deep its check went.
4
4
 
5
- beliq produces and checks the compliant document. Transmission (Peppol, PDP, KSeF, SDI), archiving, and tax-authority reporting stay with your access point.
5
+ beliq produces and checks the document. Transmission (Peppol, PDP, KSeF, SDI), archiving, and tax-authority reporting stay with your access point.
6
6
 
7
7
  ## Tools
8
8
 
9
- - **`beliq_validate_einvoice`** - validate a UBL/CII XML invoice (inline or by file path) or a Factur-X/ZUGFeRD PDF (by file path). Returns the verdict, the detected format and profile, the ruleset (Schematron) version it was checked against, and every error and warning with its rule id, severity, location, and message.
10
- - **`beliq_parse_einvoice`** - parse a UBL/CII XML invoice or a Factur-X/ZUGFeRD PDF into a structured EN 16931 invoice (number, dates, currency, seller, buyer, lines, totals). Returns the detected format and profile and the extracted invoice.
11
- - **`beliq_generate_einvoice`** - generate a compliant document (XRechnung, ZUGFeRD, Factur-X, or Peppol BIS) from an EN 16931 invoice object. XML comes back inline; a PDF is written to the `outputPath` you give. Validates the result before returning by default (`verify`), so a non-compliant document fails rather than coming back.
12
- - **`beliq_convert_einvoice`** - convert a document from one EN 16931 format to another (`targetFormat` of cii, ubl, xrechnung, peppol-bis, facturx, or zugferd). An XML target comes back inline; a PDF target is written to `outputPath`. Reports any elements the conversion could not carry across.
9
+ - **`beliq_validate_einvoice`** - validate an invoice XML (inline or by file path) or a Factur-X/ZUGFeRD PDF (by file path). Returns the verdict, how deep the check went, the detected format and profile, the ruleset versions it was checked against, and every error and warning with its rule id, severity, location, and message. For a PDF the verdict is about the embedded XML; the PDF itself is not checked.
10
+ - **`beliq_parse_einvoice`** - parse a UBL/CII XML invoice or a Factur-X/ZUGFeRD PDF into a structured EN 16931 invoice (number, dates, currency, seller, buyer, lines, totals). Returns the detected format and profile, the extracted invoice, and warnings that say what the document holds and the parsed invoice does not.
11
+ - **`beliq_generate_einvoice`** - generate a document from an EN 16931 invoice object. XML comes back inline; a PDF is written to the `outputPath` you give. By default (`verify`) the API validates the document before returning it, and a document that fails comes back as a tool error with the failed rules. The tool description tells the model to ask for missing invoice data instead of making it up.
12
+ - **`beliq_convert_einvoice`** - convert a document from one EN 16931 format to another. The result is XML, inline, for every target, `facturx` and `zugferd` included; a PDF comes back only when a hybrid PDF source already is the target, and is written to `outputPath`. Reports how many source elements the engine recorded as having no target equivalent.
13
13
  - **`beliq_check_account`** - verify the configured API key and report the plan and remaining quota. Calls `GET /v1/me`, which draws no quota; useful as a connection and credential smoke test.
14
14
 
15
15
  ## Installation
@@ -64,12 +64,24 @@ Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`) using the same `mc
64
64
 
65
65
  `beliq_validate_einvoice` returns a short text verdict plus a structured result:
66
66
 
67
- - `valid` is true only when there are no errors; warnings do not make a document invalid.
67
+ - `valid` is the API's verdict: the document passed the rule packs and schemas beliq runs for its format, in the versions this result names. It does not mean every recipient, validation tool or tax authority accepts it. Warnings do not make a document invalid, with one case to read beside it: `franceCtcBlockingRuleIds`.
68
+ - `verificationBadgeLabel` is the check depth as English display text, and the text verdict prints it beside `VALID` or `INVALID`. [How verification works](https://docs.beliq.eu/compliance/how-verification-works/) explains each depth. Where the API sends no label, the text says `check depth not stated` and names no depth. `verificationBadge` and `verificationTier` are the ids behind the label.
69
+ - `franceCtcBlockingRuleIds` lists the French CTC rule ids the API reports as ending a transmission through a French platform. It can be present where `valid` is true, and the text verdict then leads with it. The result stays a normal result, not a tool error. `franceCtcApplied`, `franceCtcVersion` and `franceExtendedCtcApplied` say which French packs ran.
70
+ - `pdfInput` is present when the document was a PDF: `attachmentName`, `attachmentFoundBy` and `attachmentCandidates` say which embedded file the verdict is about, and `containerChecked` is always false because the PDF itself is not checked.
68
71
  - `format` and `profileDetected` report the detected syntax and business profile.
69
- - `schematronVersion` is the exact ruleset revision the check ran against.
70
- - `errors[]` and `warnings[]` each carry `ruleId`, `severity`, `location` (an XPath when available), and `message`.
72
+ - `schematronVersion` is the ruleset revision the check ran against. The versions of the other packs and schema bundles that ran come in `ciusVersion`, `peppolVersion`, `facturxProfile`, `facturxVersion`, `italyFatturapaXsdBundle`, `italyFatturapaRuntimeVersion`, `italySdiMessaggiXsdBundle`, `italySdiMessaggiXsdVersion`, `spainFacturaeXsdBundle`, `spainFacturaeRuntimeVersion`, `sloveniaEslogXsdBundle`, `sloveniaEslogRuntimeVersion`, `polandKsefFa3XsdBundle`, `polandKsefFa3RuntimeVersion`, `romaniaRoCiusVersion` and `netherlandsNlciusVersion`, each present only when it applies.
73
+ - `rulesetChannel` is present when a ruleset channel was selected, which an organisation whose default channel is `previous` does with no input of this tool. `rulesetFellBack` and `rulesetSunsetsOn` come with it when they apply.
74
+ - `sha256` is the hash of the document that was checked, `rulesetSha256` and `rulesetArtifacts` fingerprint the rule files that ran, and `driftCheckedAt` is when those files were last confirmed to match their upstream sources.
75
+ - `verified` is false only when no ruleset ran, which validate never returns; see generate below.
76
+ - `errors` and `warnings` each carry `ruleId`, `severity`, `location` (an XPath when available), and `message`. `errorCount` and `warningCount` are their lengths.
71
77
 
72
- `beliq_parse_einvoice` returns the detected `format` and `profileDetected` plus the extracted `invoice` object (EN 16931 fields: number, dates, currency, seller, buyer, lines, totals, and any national extensions present).
78
+ The structured result carries every field of the API's validation result. Its shape is checked against the SDK's type, so a field the API adds cannot go missing here without failing the build.
79
+
80
+ `beliq_parse_einvoice` returns a text summary plus a structured result:
81
+
82
+ - `format`, `profileDetected` and `profileUrn` report the detected syntax and profile; `businessProcessId` and `franceCtcDetected` are present when the document states them.
83
+ - `invoice` is the extracted invoice object (EN 16931 fields: number, dates, currency, seller, buyer, lines, totals). A field the API holds no value for is left out.
84
+ - `warnings` says what the document holds and the parsed invoice does not: each entry has a `code`, a `message`, and, by code, a `field`, the `terms` (EN 16931 business groups) or the `elements` (paths with counts) concerned. The text summary lists them.
73
85
 
74
86
  `beliq_generate_einvoice` returns a short text summary (with the XML document appended for XML output) plus a structured result:
75
87
 
@@ -78,25 +90,30 @@ Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`) using the same `mc
78
90
  - `outputPath` and `bytesWritten` are set when the document was written to disk: always for a PDF, and for XML when you set `outputPath`. The call never overwrites an existing file, so pick a path that does not exist.
79
91
  - `pdfKind` is present only for PDF output: `hybrid` (a PDF/A-3 with the XML embedded, for facturx and zugferd) or `visualization` (rendered pages with no XML inside, for xrechnung and peppol-bis, whose legal document stays the XML).
80
92
  - `schematronVersion` is the ruleset (Schematron) revision the document was checked against.
93
+ - `outputEnvelope` is the envelope the document is wrapped in, when the API names one.
81
94
  - `sha256` is the lowercase-hex SHA-256 of the returned document bytes, so `sha256sum` on the file at `outputPath` reproduces it.
82
95
  - `rulesetSha256` is one combined fingerprint of the rule artifacts the document was checked against, present when a ruleset ran.
83
96
  - `livemode` is true for a `blq_live_` key and false for a `blq_test_` sandbox key, whose output is [marked as a specimen](https://docs.beliq.eu/api-reference/test-mode/#sandbox-markers) and is not a production invoice.
84
- - `validationResult` is the verdict on the generated document: `valid`, the `schematronVersion` it ran, and `errors[]`/`warnings[]` in the same shape as validate returns. With the default `verify: true`, a document that fails validation comes back as a tool error instead of a result. With `verify: false` no ruleset runs: `valid` is false and `errors`/`warnings` are empty because nothing checked the document, not because it failed.
97
+ - `validationResult` is the API's verdict on the generated document, with every field validate returns, the check depth in `verificationBadgeLabel` included; the text summary names that depth. The verdict is about the invoice XML, so for a PDF the text says so and names the kind of PDF. With the default `verify: true`, a document that fails validation comes back as a tool error that lists the failed rules. With `verify: false` no ruleset runs: `verified` is false, `errors` and `warnings` are empty because nothing checked the document, and the text says `not validated`.
85
98
 
86
99
  `beliq_convert_einvoice` returns a short text summary (with the XML document appended for an XML target) plus a structured result:
87
100
 
88
- - `output` is `pdf` for a facturx or zugferd target and `xml` for the others, and `contentType` is the matching media type (`application/pdf` or `application/xml`).
89
- - `xml` is the converted document inline, present only for an XML target.
90
- - `outputPath` and `bytesWritten` are set when the document was written to disk: always for a PDF target, and for an XML target when you set `outputPath`. Like generate, it never overwrites an existing file.
101
+ - `output` is what came back, read from the response: `xml` for every target when the source is XML, `facturx` and `zugferd` included, and `pdf` only when a hybrid PDF source already is the `facturx` or `zugferd` target. `contentType` is the matching media type.
102
+ - `xml` is the converted document inline, present only when XML came back.
103
+ - `outputPath` and `bytesWritten` are set when the document was written to disk: always for a PDF, and for XML when you set `outputPath`. Like generate, it never overwrites an existing file.
91
104
  - `sourceFormat` is the format the engine read, and `targetFormat` the format it produced (the `targetFormat` you asked for when the API does not name one).
92
105
  - `profileDetected` is the profile the engine recognised on the source document, when it recognised one.
93
- - `lostElementsCount` and `lostElements` count and name the source elements that had no equivalent in the target format; `0` means nothing was lost at the element level.
106
+ - `conversionTools` names the toolchain that did the conversion, when the API names one.
107
+ - `livemode` is true for a `blq_live_` key and false for a `blq_test_` sandbox key.
108
+ - `lostElementsCount` and `lostElements` count and name the source elements the engine recorded as having no target equivalent. A loss the engine does not detect is not counted, so `0` does not say that every field of the source arrived.
109
+
110
+ A successful convert means the API validated the converted XML before returning it. For a `facturx` or `zugferd` target at profile `en16931` that validation is the one for plain EN 16931 CII: no Factur-X profile schema is checked. It carries no verdict and no check depth. A converted document that fails comes back as a tool error that lists the failed rules.
94
111
 
95
112
  A PDF (Factur-X / ZUGFeRD) must be passed by `documentPath` for validate, parse, and convert, not inlined as text.
96
113
 
97
114
  ## Agent skill
98
115
 
99
- [`skill/SKILL.md`](./skill/SKILL.md) is a portable agent skill that teaches a model when to validate, how to read `errors`/`warnings`, and how to report a verdict, using the tools above. Drop it into a skills directory for an agent that should validate invoices on request.
116
+ [`skill/SKILL.md`](./skill/SKILL.md) is a portable agent skill that teaches a model when to validate, how to read `errors`/`warnings`, and how to report a verdict with its check depth, using the tools above. Drop it into a skills directory for an agent that should validate invoices on request.
100
117
 
101
118
  ## Development
102
119
 
@@ -104,8 +121,8 @@ This server depends on the published [`@beliq/sdk`](https://www.npmjs.com/packag
104
121
 
105
122
  - `npm install`
106
123
  - `npm run build` - compile to `dist/`
107
- - `npm run typecheck`
108
- - `npm run lint`
124
+ - `npm run typecheck` - src and tests
125
+ - `npm run lint` - ESLint with type information, whole repository
109
126
  - `npm test` - unit tests (result summary) and an in-memory MCP round-trip with a fake SDK client
110
127
  - `BELIQ_API_KEY=your-key npm run test:integration` - live smoke tests against the real API
111
128
  - `npm run scrub:check` - check for em-dashes in source and docs
package/dist/schema.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { z } from 'zod';
2
- import { LIVE_CONVERT_SOURCE_FORMATS, LIVE_CONVERT_TARGET_FORMATS, LIVE_GENERATE_STANDARDS, LIVE_PARSE_FORMATS, LIVE_PROFILES, } from '@beliq/sdk';
3
- // The tools expose the LIVE, authority-pinned public option sets (LPD-1),
4
- // imported from the SDK so this stays one source of truth: callers pass a
5
- // syntax family or a live standard, never a provisional/withheld profile. The
6
- // engine can detect more; the public surface stays narrow.
2
+ import { LIVE_CONVERT_SOURCE_FORMATS, LIVE_CONVERT_TARGET_FORMATS, LIVE_GENERATE_STANDARDS, LIVE_PARSE_FORMATS, LIVE_PROFILES, LIVE_VALIDATE_FORMATS, } from '@beliq/sdk';
3
+ // The tools expose the SDK's LIVE_* option sets, imported rather than retyped
4
+ // so this stays one source of truth. Each carries every value the API accepts
5
+ // for its operation: generate offers all eight standards, the four national
6
+ // XSD ones included, while convert offers fewer because the API's own convert
7
+ // enums do. GET /v1/rulesets carries the badge saying how deep each format's
8
+ // check goes.
7
9
  export const validateInputShape = {
8
10
  document: z
9
11
  .string()
@@ -14,15 +16,15 @@ export const validateInputShape = {
14
16
  .string()
15
17
  .min(1)
16
18
  .optional()
17
- .describe('Path to an invoice file on disk: a UBL/CII XML, or a PDF carrying embedded XML (Factur-X / ZUGFeRD). Provide this OR document.'),
19
+ .describe('Path to an invoice file on disk: an XML document, or a PDF carrying embedded XML (Factur-X / ZUGFeRD). Provide this OR document.'),
18
20
  format: z
19
- .enum(['auto', 'cii', 'ubl'])
21
+ .enum(LIVE_VALIDATE_FORMATS)
20
22
  .optional()
21
- .describe("Source syntax hint. 'auto' (default) detects CII vs UBL from the document."),
23
+ .describe("Source syntax hint. 'auto' (default) detects the format from the document."),
22
24
  franceCtc: z
23
25
  .boolean()
24
26
  .optional()
25
- .describe('Apply the French CTC (Factur-X / Chorus Pro) rule overlay during validation.'),
27
+ .describe('Also apply the French CTC rules (BR-FR-CTC) during validation.'),
26
28
  };
27
29
  const issueShape = z.object({
28
30
  ruleId: z.string(),
@@ -30,16 +32,75 @@ const issueShape = z.object({
30
32
  location: z.string().optional(),
31
33
  message: z.string(),
32
34
  });
33
- export const validateOutputShape = {
34
- valid: z.boolean(),
35
+ const text = () => z.string().optional();
36
+ /**
37
+ * Every field of the SDK's ValidationResult, passed on as the API sent it. The
38
+ * `satisfies` holds this list to the SDK's type, so a field the API gains
39
+ * fails the typecheck at the SDK bump instead of being dropped without notice.
40
+ */
41
+ const validationResultShape = {
42
+ valid: z
43
+ .boolean()
44
+ .describe('Whether the document passed the rule packs and schemas beliq runs for its format, in the versions this result names. Not a statement that every recipient or tax authority accepts it.'),
45
+ verified: z
46
+ .boolean()
47
+ .optional()
48
+ .describe('False only when no ruleset ran (generate with verify: false). Then valid says nothing about the document.'),
35
49
  format: z.string(),
36
- profileDetected: z.string().optional(),
37
- schematronVersion: z.string().optional(),
38
- errorCount: z.number(),
39
- warningCount: z.number(),
50
+ profileDetected: text(),
51
+ verificationTier: text(),
52
+ verificationBadge: text().describe('Id of the check depth. Compare this, not the label.'),
53
+ verificationBadgeLabel: text().describe('The check depth as English display text. Print it as it is. Absent when the API states no depth for this verdict: then say the check depth was not stated, and name none.'),
54
+ schematronVersion: text(),
55
+ ciusVersion: text(),
56
+ peppolVersion: text(),
57
+ facturxProfile: text(),
58
+ facturxVersion: text(),
59
+ franceCtcApplied: z.boolean().optional(),
60
+ franceCtcVersion: text(),
61
+ franceExtendedCtcApplied: z.boolean().optional(),
62
+ franceCtcBlockingRuleIds: z
63
+ .array(z.string())
64
+ .optional()
65
+ .describe('French CTC rule ids the API reports as ending a transmission through a French platform. Present also where valid is true, and never empty: when present, lead with it.'),
66
+ italyFatturapaXsdBundle: text(),
67
+ italyFatturapaRuntimeVersion: text(),
68
+ italySdiMessaggiXsdBundle: text(),
69
+ italySdiMessaggiXsdVersion: text(),
70
+ spainFacturaeXsdBundle: text(),
71
+ spainFacturaeRuntimeVersion: text(),
72
+ sloveniaEslogXsdBundle: text(),
73
+ sloveniaEslogRuntimeVersion: text(),
74
+ polandKsefFa3XsdBundle: text(),
75
+ polandKsefFa3RuntimeVersion: text(),
76
+ romaniaRoCiusVersion: text(),
77
+ netherlandsNlciusVersion: text(),
78
+ rulesetChannel: text(),
79
+ rulesetFellBack: z.boolean().optional(),
80
+ rulesetSunsetsOn: text(),
81
+ pdfInput: z
82
+ .object({
83
+ containerChecked: z.boolean(),
84
+ attachmentName: z.string().optional(),
85
+ attachmentFoundBy: z.string(),
86
+ attachmentCandidates: z.number(),
87
+ })
88
+ .optional()
89
+ .describe('Present when the document was a PDF: which embedded file the verdict is about. containerChecked is always false, because the PDF itself is not checked.'),
90
+ sha256: text(),
91
+ rulesetSha256: text(),
92
+ rulesetArtifacts: z
93
+ .array(z.object({ key: z.string(), version: z.string(), fileSha256: z.string() }))
94
+ .optional(),
95
+ driftCheckedAt: text(),
40
96
  errors: z.array(issueShape),
41
97
  warnings: z.array(issueShape),
42
98
  };
99
+ export const validateOutputShape = {
100
+ ...validationResultShape,
101
+ errorCount: z.number(),
102
+ warningCount: z.number(),
103
+ };
43
104
  export const checkAccountOutputShape = {
44
105
  ok: z.boolean(),
45
106
  status: z.number(),
@@ -65,10 +126,23 @@ export const parseInputShape = {
65
126
  };
66
127
  // The extracted invoice is passed through verbatim from the API; the shape is
67
128
  // documented on the endpoint, so the tool reports it without re-modelling it.
129
+ // The other fields are held to the SDK's type like the validation result.
68
130
  export const parseOutputShape = {
69
131
  format: z.string(),
70
- profileDetected: z.string().optional(),
132
+ profileDetected: text(),
133
+ profileUrn: text(),
134
+ businessProcessId: text(),
135
+ franceCtcDetected: z.boolean().optional(),
71
136
  invoice: z.record(z.string(), z.unknown()),
137
+ warnings: z
138
+ .array(z.object({
139
+ code: z.string(),
140
+ message: z.string(),
141
+ field: z.string().optional(),
142
+ terms: z.array(z.string()).optional(),
143
+ elements: z.array(z.object({ path: z.string(), count: z.number() })).optional(),
144
+ }))
145
+ .describe('What the document holds and the parsed invoice does not. Report these with the invoice: an empty list is the only case in which the invoice is all the API read.'),
72
146
  };
73
147
  // ── Generate ──────────────────────────────────────────────────────────────
74
148
  const partyAddress = z
@@ -125,7 +199,7 @@ export const generateInputShape = {
125
199
  verify: z
126
200
  .boolean()
127
201
  .optional()
128
- .describe('Validate the generated document before returning and fail if it is not compliant. Defaults to true.'),
202
+ .describe('Validate the generated document before returning it, and fail if it does not pass. Defaults to true.'),
129
203
  outputPath: z
130
204
  .string()
131
205
  .min(1)
@@ -137,6 +211,7 @@ export const generateOutputShape = {
137
211
  contentType: z.string(),
138
212
  schematronVersion: z.string().optional(),
139
213
  pdfKind: z.string().optional(),
214
+ outputEnvelope: z.string().optional(),
140
215
  outputPath: z.string().optional(),
141
216
  bytesWritten: z.number().optional(),
142
217
  xml: z.string().optional(),
@@ -145,14 +220,7 @@ export const generateOutputShape = {
145
220
  sha256: z.string().optional(),
146
221
  rulesetSha256: z.string().optional(),
147
222
  livemode: z.boolean().optional(),
148
- validationResult: z
149
- .object({
150
- valid: z.boolean(),
151
- schematronVersion: z.string().optional(),
152
- errors: z.array(issueShape),
153
- warnings: z.array(issueShape),
154
- })
155
- .optional(),
223
+ validationResult: z.object(validationResultShape).optional(),
156
224
  };
157
225
  // ── Convert ────────────────────────────────────────────────────────────────
158
226
  export const convertInputShape = {
@@ -168,7 +236,7 @@ export const convertInputShape = {
168
236
  .describe('Path to the source invoice on disk: a UBL/CII XML, or a Factur-X / ZUGFeRD PDF. Provide this OR document.'),
169
237
  targetFormat: z
170
238
  .enum(LIVE_CONVERT_TARGET_FORMATS)
171
- .describe('Format to convert to. cii, ubl, xrechnung, and peppol-bis come back as XML inline; facturx and zugferd produce a PDF and need outputPath.'),
239
+ .describe('Format to convert to. Every target comes back as XML inline, facturx and zugferd included. A PDF comes back only when a hybrid PDF source already is the facturx or zugferd target, and then needs outputPath.'),
172
240
  sourceFormat: z
173
241
  .enum(LIVE_CONVERT_SOURCE_FORMATS)
174
242
  .optional()
@@ -180,22 +248,54 @@ export const convertInputShape = {
180
248
  dropFranceCtcOverlay: z
181
249
  .boolean()
182
250
  .optional()
183
- .describe('Drop the French CTC (Factur-X / Chorus Pro) overlay from the converted document.'),
251
+ .describe('Drop the French CTC overlay from the converted document.'),
184
252
  outputPath: z
185
253
  .string()
186
254
  .min(1)
187
255
  .optional()
188
- .describe('Where to write the converted document on disk. Required for a facturx/zugferd (PDF) target, optional for an XML target. The call fails if a file already exists at the path.'),
256
+ .describe('Where to write the converted document on disk. Required when a PDF source goes to a facturx or zugferd target, optional otherwise. The call fails if a file already exists at the path.'),
189
257
  };
190
258
  export const convertOutputShape = {
191
- output: z.enum(['xml', 'pdf']),
259
+ output: z.enum(['xml', 'pdf']).describe('What came back, read from the response: xml, or pdf for a returned hybrid PDF.'),
192
260
  contentType: z.string(),
193
261
  sourceFormat: z.string().optional(),
194
262
  targetFormat: z.string().optional(),
195
263
  profileDetected: z.string().optional(),
196
264
  lostElementsCount: z.number().optional(),
197
265
  lostElements: z.array(z.string()).optional(),
266
+ conversionTools: z.string().optional(),
267
+ livemode: z.boolean().optional(),
198
268
  outputPath: z.string().optional(),
199
269
  bytesWritten: z.number().optional(),
200
270
  xml: z.string().optional(),
201
271
  };
272
+ export const GENERATE_RESULT_FIELDS = {
273
+ contentType: 'contentType',
274
+ bytes: { omitted: 'the document itself: inline as xml, or written to outputPath' },
275
+ xml: 'xml',
276
+ sha256: 'sha256',
277
+ validationResult: 'validationResult',
278
+ meta: { omitted: 'flattened, see GENERATE_META_FIELDS' },
279
+ };
280
+ export const GENERATE_META_FIELDS = {
281
+ schematronVersion: 'schematronVersion',
282
+ pdfKind: 'pdfKind',
283
+ outputEnvelope: 'outputEnvelope',
284
+ rulesetSha256: 'rulesetSha256',
285
+ rulesetArtifacts: { omitted: 'validationResult.rulesetArtifacts carries the same list' },
286
+ livemode: 'livemode',
287
+ };
288
+ export const CONVERT_RESULT_FIELDS = {
289
+ contentType: 'contentType',
290
+ bytes: { omitted: 'the document itself: inline as xml, or written to outputPath' },
291
+ meta: { omitted: 'flattened, see CONVERT_META_FIELDS' },
292
+ };
293
+ export const CONVERT_META_FIELDS = {
294
+ sourceFormat: 'sourceFormat',
295
+ targetFormat: 'targetFormat',
296
+ profileDetected: 'profileDetected',
297
+ lostElementsCount: 'lostElementsCount',
298
+ lostElements: 'lostElements',
299
+ conversionTools: 'conversionTools',
300
+ livemode: 'livemode',
301
+ };
package/dist/summary.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { checkDepth, franceBlockLines, pdfInputLine, rulesetChannelLine, verdictWord } from './verdict.js';
1
2
  /** How many issues to spell out before collapsing the rest into a count. */
2
3
  const MAX_LISTED_ISSUES = 20;
3
4
  function issueLine(issue) {
@@ -18,26 +19,40 @@ function listSection(title, issues) {
18
19
  return lines;
19
20
  }
20
21
  /**
21
- * A compact human-readable verdict for the MCP text content: the pass/fail
22
- * line with format, profile, and ruleset version, then the errors and warnings
23
- * (capped). The full structured result rides alongside in structuredContent.
22
+ * A compact human-readable verdict for the MCP text content. A France CTC
23
+ * block leads, because the API reports it where `valid` is true and a reader
24
+ * of the verdict line alone would miss it. Then the API's verdict word with
25
+ * the check depth, the format, the profile and the ruleset version, what the
26
+ * API says about the ruleset channel and a PDF input, and the errors and
27
+ * warnings (capped). The full structured result rides alongside in
28
+ * structuredContent.
24
29
  */
25
30
  export function summarizeValidation(result) {
26
31
  const errors = (result.errors ?? []);
27
32
  const warnings = (result.warnings ?? []);
28
- const verdict = result.valid ? 'VALID' : 'INVALID';
29
33
  const profile = result.profileDetected ? ` (profile ${result.profileDetected})` : '';
30
- const schematron = result.schematronVersion
31
- ? `, checked against Schematron ${result.schematronVersion}`
32
- : '';
33
- const header = `Validation: ${verdict}. Format ${result.format}${profile}${schematron}. ` +
34
+ // Nothing ran when `verified` is false, so there is no depth and no ruleset to name.
35
+ const ran = result.verified !== false;
36
+ const depth = ran ? ` (${checkDepth(result)})` : '';
37
+ const schematron = ran && result.schematronVersion ? `, checked against Schematron ${result.schematronVersion}` : '';
38
+ const header = `Validation: ${verdictWord(result)}${depth}. Format ${result.format}${profile}${schematron}. ` +
34
39
  `${plural(errors.length, 'error')}, ${plural(warnings.length, 'warning')}.`;
35
- return [header, ...listSection('Errors', errors), ...listSection('Warnings', warnings)].join('\n');
40
+ return [
41
+ ...franceBlockLines(result),
42
+ header,
43
+ rulesetChannelLine(result),
44
+ pdfInputLine(result),
45
+ ...listSection('Errors', errors),
46
+ ...listSection('Warnings', warnings),
47
+ ]
48
+ .filter((line) => line !== undefined)
49
+ .join('\n');
36
50
  }
37
51
  /**
38
- * A one-line human-readable summary of a parse: the detected syntax and
39
- * profile, then the invoice number, currency, line count, and gross total. The
40
- * full structured invoice rides alongside in structuredContent.
52
+ * A human-readable summary of a parse: the detected syntax and profile, then
53
+ * the invoice number, currency, line count, and gross total, then each warning
54
+ * the API sent about what the document holds and the parsed invoice does not.
55
+ * The full structured result rides alongside in structuredContent.
41
56
  */
42
57
  export function summarizeParse(result) {
43
58
  const invoice = result.invoice;
@@ -47,20 +62,41 @@ export function summarizeParse(result) {
47
62
  const gross = invoice?.totalGrossAmount != null
48
63
  ? `, gross ${invoice.totalGrossAmount} ${invoice.currencyCode ?? ''}`.trimEnd()
49
64
  : '';
50
- return `Parsed a ${result.format}${profile} document: ${number}, ${plural(lines, 'line')}${gross}.`;
65
+ const head = `Parsed a ${result.format}${profile} document: ${number}, ${plural(lines, 'line')}${gross}.`;
66
+ const warnings = result.warnings ?? [];
67
+ if (warnings.length === 0)
68
+ return head;
69
+ const listed = warnings.map((w) => {
70
+ const field = w.field ? ` (field ${w.field})` : '';
71
+ const terms = w.terms?.length ? ` Terms: ${w.terms.join(', ')}.` : '';
72
+ const elements = w.elements?.length
73
+ ? ` ${plural(w.elements.length, 'element path')}; the structured result lists them.`
74
+ : '';
75
+ return `- ${w.code}${field}: ${w.message}${terms}${elements}`;
76
+ });
77
+ return [head, `${plural(warnings.length, 'warning')}:`, ...listed].join('\n');
51
78
  }
52
79
  /**
53
- * A human-readable summary of a generate. For an XML result the document is
54
- * appended in full so a client without structured output still receives it; a
55
- * PDF is reported by the path it was written to. When the seal is present the
56
- * document sha256 and validation verdict are stated so the model can cite them.
80
+ * A human-readable summary of a generate, with the check depth of the
81
+ * validation the API ran on the document, or that none ran. The verdict is
82
+ * about the invoice XML, so for a PDF the summary says so and names the kind
83
+ * of PDF. A France CTC block in the verdict leads. For an XML result
84
+ * the document is appended in full so a client without structured output still
85
+ * receives it; a PDF is reported by the path it was written to. The document
86
+ * sha256 and the verdict are stated so the model can cite them.
57
87
  */
58
88
  export function summarizeGenerate(g) {
59
- const checked = g.schematronVersion ? `, checked against Schematron ${g.schematronVersion}` : '';
89
+ const ran = g.verdict?.verified !== false;
90
+ const schematron = ran && g.schematronVersion ? `checked against Schematron ${g.schematronVersion}` : '';
91
+ const depth = g.verdict === undefined ? '' : ran ? checkDepth(g.verdict) : 'not validated';
92
+ const check = [schematron, depth].filter((part) => part !== '').join(', ');
93
+ const pdfKind = g.output === 'pdf' && g.pdfKind ? ` (${g.pdfKind})` : '';
94
+ const checked = check === '' ? '' : g.output === 'pdf' ? `; invoice XML ${check}` : `, ${check}`;
95
+ const france = g.verdict ? franceBlockLines(g.verdict).map((line) => `${line}\n`).join('') : '';
60
96
  const seal = g.sha256
61
- ? ` Document sha256 ${g.sha256}${g.valid === undefined ? '' : `; validation ${g.valid ? 'passed' : 'failed'}`}.`
97
+ ? ` Document sha256 ${g.sha256}${g.verdict === undefined ? '' : `; verdict ${verdictWord(g.verdict)}`}.`
62
98
  : '';
63
- const header = `Generated a ${g.standard} ${g.output} document${checked}.${seal}`;
99
+ const header = `${france}Generated a ${g.standard} ${g.output} document${pdfKind}${checked}.${seal}`;
64
100
  if (g.output === 'pdf') {
65
101
  const where = g.outputPath ? ` Written to ${g.outputPath} (${plural(g.bytesWritten ?? 0, 'byte')}).` : '';
66
102
  return `${header}${where}`;
@@ -71,18 +107,22 @@ export function summarizeGenerate(g) {
71
107
  }
72
108
  /**
73
109
  * A human-readable summary of a convert: the source and target formats, any
74
- * profile, and how many elements the conversion could not carry across (so a
75
- * lossy conversion is visible). For an XML target the document is appended in
76
- * full; a PDF target is reported by the path it was written to.
110
+ * profile, that the API validated the result, and how many source elements the
111
+ * engine recorded as having no target equivalent. A convert carries no verdict,
112
+ * so no check depth is named. For XML the document is appended in full; a PDF
113
+ * is reported by the path it was written to.
77
114
  */
78
115
  export function summarizeConvert(c) {
79
116
  const fromClause = c.sourceFormat ? `${c.sourceFormat} ` : '';
80
117
  const target = c.targetFormat ?? 'the target format';
81
118
  const profile = c.profileDetected ? ` (profile ${c.profileDetected})` : '';
119
+ const checked = c.output === 'pdf'
120
+ ? ' The API validated the embedded XML before returning it; the PDF around it was not checked.'
121
+ : ' The API validated the converted XML before returning it.';
82
122
  const lost = c.lostElementsCount && c.lostElementsCount > 0
83
- ? ` ${plural(c.lostElementsCount, 'element')} could not be carried across.`
123
+ ? ` The engine recorded ${plural(c.lostElementsCount, 'source element')} with no target equivalent.`
84
124
  : '';
85
- const header = `Converted ${fromClause}to ${target}${profile}.${lost}`;
125
+ const header = `Converted ${fromClause}to ${target}${profile}, returned as ${c.output === 'pdf' ? 'a PDF' : 'XML'}.${checked}${lost}`;
86
126
  if (c.output === 'pdf') {
87
127
  const where = c.outputPath ? ` Written to ${c.outputPath} (${plural(c.bytesWritten ?? 0, 'byte')}).` : '';
88
128
  return `${header}${where}`;
@@ -2,9 +2,10 @@ import { convertInputShape, convertOutputShape } from '../schema.js';
2
2
  import { runConvert } from './shared.js';
3
3
  const description = [
4
4
  'Convert an EU electronic invoice from one EN 16931 format to another.',
5
- 'Pass the source document (inline via document, or a file via documentPath) and set targetFormat to cii, ubl, xrechnung, peppol-bis, facturx, or zugferd.',
6
- 'An XML target (cii, ubl, xrechnung, peppol-bis) comes back inline; a PDF target (facturx, zugferd) is written to the outputPath you give.',
7
- 'The result reports any elements the conversion could not carry across, so a lossy conversion is visible rather than silent.',
5
+ 'Pass the source document (inline via document, or a file via documentPath) and set targetFormat.',
6
+ 'The converted document comes back as XML inline for every target, facturx and zugferd included: an XML source is not wrapped into a hybrid PDF. A PDF comes back only when a hybrid PDF source already is the target, and is then written to the outputPath you give.',
7
+ 'A successful result means the API validated the converted XML before returning it. For a facturx or zugferd target at profile en16931 that validation is the one for plain EN 16931 CII: no Factur-X profile schema is checked. It carries no verdict and no check depth; a converted document that fails comes back as an error that lists the failed rules.',
8
+ 'lostElementsCount is how many source elements the engine recorded as having no target equivalent. A loss the engine does not detect is not counted, so 0 does not say that every field of the source arrived.',
8
9
  'beliq produces the converted document; transmission (Peppol, PDP, KSeF, SDI), archiving, and tax-authority reporting stay with your access point.',
9
10
  ].join(' ');
10
11
  export function registerConvert(server, deps) {
@@ -1,11 +1,12 @@
1
1
  import { generateInputShape, generateOutputShape } from '../schema.js';
2
2
  import { runGenerate } from './shared.js';
3
3
  const description = [
4
- 'Generate a compliant EU electronic invoice from an EN 16931 invoice object.',
5
- 'Set standard to the target (xrechnung, zugferd, facturx, or peppol-bis) and pass the invoice fields.',
4
+ 'Generate an EU electronic invoice from an EN 16931 invoice object.',
5
+ 'Set standard to the target format and pass the invoice fields.',
6
+ 'Use only invoice data the user gave. When a field the target format requires is missing, ask the user for it: do not make up a value so that validation passes.',
6
7
  "output 'xml' (default) returns the document inline; 'pdf' needs outputPath to write it to, and is a hybrid PDF/A-3 with the XML embedded for facturx and zugferd, or a visualization with no embedded XML for xrechnung and peppol-bis (whose legal document stays the XML).",
7
- 'By default the result is validated before it is returned (verify), so a non-compliant document fails rather than coming back.',
8
- 'beliq produces the compliant document; transmission (Peppol, PDP, KSeF, SDI), archiving, and tax-authority reporting stay with your access point.',
8
+ 'By default the API validates the document before returning it (verify), and a document that fails comes back as an error that lists the failed rules. validationResult carries the verdict, with the check depth of that validation in verificationBadgeLabel.',
9
+ 'beliq produces the document; transmission (Peppol, PDP, KSeF, SDI), archiving, and tax-authority reporting stay with your access point.',
9
10
  ].join(' ');
10
11
  export function registerGenerate(server, deps) {
11
12
  server.registerTool('beliq_generate_einvoice', {
@@ -3,8 +3,8 @@ import { runParse } from './shared.js';
3
3
  const description = [
4
4
  'Parse an EU electronic invoice into a structured EN 16931 invoice object.',
5
5
  'Accepts a UBL or CII XML document (inline via document, or a file via documentPath), or a Factur-X / ZUGFeRD PDF via documentPath.',
6
- 'Returns the detected format and profile and the extracted invoice (number, dates, currency, seller, buyer, lines, totals, and more).',
7
- 'Use it to read the contents of an invoice; use validate to check compliance.',
6
+ 'Returns the detected format and profile, the extracted invoice (number, dates, currency, seller, buyer, lines, totals, and more), and warnings that say what the document holds and the parsed invoice does not.',
7
+ 'Report the warnings with the invoice. Use it to read the contents of an invoice; use validate to check it against the rules.',
8
8
  ].join(' ');
9
9
  export function registerParse(server, deps) {
10
10
  server.registerTool('beliq_parse_einvoice', {
@@ -1,5 +1,6 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { BeliqApiError, isProfileAllowedForStandard, profilesForStandard, } from '@beliq/sdk';
3
+ import { sniffContentType, toBytes } from '@beliq/sdk/helpers';
3
4
  import { summarizeConvert, summarizeGenerate, summarizeParse, summarizeValidation } from '../summary.js';
4
5
  function errorResult(message) {
5
6
  return { content: [{ type: 'text', text: message }], isError: true };
@@ -10,8 +11,19 @@ function nonEmpty(value) {
10
11
  /** A bad tool input that should come back as a normal (non-fatal) tool error. */
11
12
  class BeliqInputError extends Error {
12
13
  }
13
- /** Convert targets that produce a PDF rather than an inline XML document. */
14
- const PDF_CONVERT_TARGETS = new Set(['facturx', 'zugferd']);
14
+ /** The convert targets for which the API can hand a hybrid PDF source back as a PDF. */
15
+ const HYBRID_CONVERT_TARGETS = new Set(['facturx', 'zugferd']);
16
+ /**
17
+ * An API error as a tool error. A 422 from generate or convert carries the
18
+ * validation result of the document the API refused, so the failed rules are
19
+ * appended for the model to act on.
20
+ */
21
+ function apiErrorResult(err) {
22
+ const code = err.code ? ` (${err.code})` : '';
23
+ const refused = err.details?.validationResult;
24
+ const rules = refused && typeof refused === 'object' ? `\n${summarizeValidation(refused)}` : '';
25
+ return errorResult(`beliq API error ${err.status}${code}: ${err.message}${rules}`);
26
+ }
15
27
  /**
16
28
  * Resolve a document-carrying input to the bytes/text the SDK reads. Exactly
17
29
  * one of document (inline XML) or documentPath (a file on disk) must be set; a
@@ -29,8 +41,10 @@ async function resolveDocument(input) {
29
41
  }
30
42
  /**
31
43
  * Validate one document and shape the MCP result: a human-readable verdict in
32
- * the text content plus the full structured result. Input and API errors come
33
- * back as isError so the model can self-correct; only unexpected faults throw.
44
+ * the text content plus every field of the API result. A document that is not
45
+ * valid, or that carries France CTC blocking rule ids, is a normal result:
46
+ * isError is for input and API errors, so the model can self-correct. Only
47
+ * unexpected faults throw.
34
48
  */
35
49
  export async function runValidate(input, deps) {
36
50
  let document;
@@ -51,23 +65,12 @@ export async function runValidate(input, deps) {
51
65
  const warnings = result.warnings ?? [];
52
66
  return {
53
67
  content: [{ type: 'text', text: summarizeValidation(result) }],
54
- structuredContent: {
55
- valid: result.valid,
56
- format: result.format,
57
- profileDetected: result.profileDetected,
58
- schematronVersion: result.schematronVersion,
59
- errorCount: errors.length,
60
- warningCount: warnings.length,
61
- errors,
62
- warnings,
63
- },
68
+ structuredContent: { ...result, errorCount: errors.length, warningCount: warnings.length, errors, warnings },
64
69
  };
65
70
  }
66
71
  catch (err) {
67
- if (err instanceof BeliqApiError) {
68
- const code = err.code ? ` (${err.code})` : '';
69
- return errorResult(`beliq API error ${err.status}${code}: ${err.message}`);
70
- }
72
+ if (err instanceof BeliqApiError)
73
+ return apiErrorResult(err);
71
74
  throw err;
72
75
  }
73
76
  }
@@ -107,8 +110,8 @@ export async function runCheckAccount(deps) {
107
110
  }
108
111
  /**
109
112
  * Parse one document into a structured EN 16931 invoice and shape the MCP
110
- * result: a one-line summary in the text content plus the full invoice in
111
- * structuredContent. Input and API errors come back as isError so the model can
113
+ * result: a summary with the API's warnings in the text content plus the full
114
+ * result in structuredContent. Input and API errors come back as isError so the model can
112
115
  * self-correct; only unexpected faults throw.
113
116
  */
114
117
  export async function runParse(input, deps) {
@@ -125,27 +128,21 @@ export async function runParse(input, deps) {
125
128
  const result = await deps.client.parse(document, { format: input.format });
126
129
  return {
127
130
  content: [{ type: 'text', text: summarizeParse(result) }],
128
- structuredContent: {
129
- format: result.format,
130
- profileDetected: result.profileDetected,
131
- invoice: result.invoice,
132
- },
131
+ structuredContent: { ...result, warnings: result.warnings ?? [] },
133
132
  };
134
133
  }
135
134
  catch (err) {
136
- if (err instanceof BeliqApiError) {
137
- const code = err.code ? ` (${err.code})` : '';
138
- return errorResult(`beliq API error ${err.status}${code}: ${err.message}`);
139
- }
135
+ if (err instanceof BeliqApiError)
136
+ return apiErrorResult(err);
140
137
  throw err;
141
138
  }
142
139
  }
143
140
  /**
144
- * Generate a compliant document from an EN 16931 invoice. XML comes back inline
145
- * in the text content; a PDF (or an XML with outputPath set) is written to disk
146
- * and reported by path. verify defaults to true so the tool fails closed rather
147
- * than hand back a non-compliant document. Input and API errors come back as
148
- * isError so the model can self-correct.
141
+ * Generate a document from an EN 16931 invoice. XML comes back inline in the
142
+ * text content; a PDF (or an XML with outputPath set) is written to disk and
143
+ * reported by path. verify defaults to true, so the API refuses a document
144
+ * that fails its own validation instead of handing it back. Input and API
145
+ * errors come back as isError so the model can self-correct.
149
146
  */
150
147
  export async function runGenerate(input, deps) {
151
148
  const output = input.output ?? 'xml';
@@ -180,10 +177,8 @@ export async function runGenerate(input, deps) {
180
177
  });
181
178
  }
182
179
  catch (err) {
183
- if (err instanceof BeliqApiError) {
184
- const code = err.code ? ` (${err.code})` : '';
185
- return errorResult(`beliq API error ${err.status}${code}: ${err.message}`);
186
- }
180
+ if (err instanceof BeliqApiError)
181
+ return apiErrorResult(err);
187
182
  throw err;
188
183
  }
189
184
  let bytesWritten;
@@ -208,7 +203,8 @@ export async function runGenerate(input, deps) {
208
203
  bytesWritten,
209
204
  xml: output === 'xml' ? result.xml : undefined,
210
205
  sha256: result.sha256,
211
- valid: vr?.valid,
206
+ pdfKind: result.meta.pdfKind,
207
+ verdict: vr,
212
208
  });
213
209
  return {
214
210
  content: [{ type: 'text', text }],
@@ -217,29 +213,24 @@ export async function runGenerate(input, deps) {
217
213
  contentType: result.contentType,
218
214
  schematronVersion: result.meta.schematronVersion,
219
215
  pdfKind: result.meta.pdfKind,
216
+ outputEnvelope: result.meta.outputEnvelope,
220
217
  outputPath,
221
218
  bytesWritten,
222
219
  xml: output === 'xml' ? result.xml : undefined,
223
220
  sha256: result.sha256,
224
221
  rulesetSha256: result.meta.rulesetSha256,
225
222
  livemode: result.meta.livemode,
226
- validationResult: vr
227
- ? {
228
- valid: vr.valid,
229
- schematronVersion: vr.schematronVersion,
230
- errors: vr.errors ?? [],
231
- warnings: vr.warnings ?? [],
232
- }
233
- : undefined,
223
+ validationResult: vr ? { ...vr, errors: vr.errors ?? [], warnings: vr.warnings ?? [] } : undefined,
234
224
  },
235
225
  };
236
226
  }
237
227
  /**
238
- * Convert a document from one EN 16931 format to another. An XML target comes
239
- * back inline; a PDF target (facturx / zugferd) is written to disk and reported
240
- * by path. Any elements the conversion could not carry across are surfaced, so
241
- * a lossy conversion is visible. Input and API errors come back as isError so
242
- * the model can self-correct.
228
+ * Convert a document from one EN 16931 format to another. The API returns XML
229
+ * for every target, facturx and zugferd included, and it comes back inline. A
230
+ * PDF comes back only when a hybrid PDF source already is the target; it is
231
+ * written to disk and reported by path. The count of lost elements is what the
232
+ * engine recorded, and a loss it does not detect is not in it. Input and API
233
+ * errors come back as isError so the model can self-correct.
243
234
  */
244
235
  export async function runConvert(input, deps) {
245
236
  let document;
@@ -251,10 +242,10 @@ export async function runConvert(input, deps) {
251
242
  return errorResult(err.message);
252
243
  return errorResult(`Could not read the document: ${err.message}`);
253
244
  }
254
- const output = PDF_CONVERT_TARGETS.has(input.targetFormat) ? 'pdf' : 'xml';
255
245
  const outputPath = nonEmpty(input.outputPath) ? input.outputPath.trim() : undefined;
256
- if (output === 'pdf' && !outputPath) {
257
- return errorResult(`Converting to ${input.targetFormat} produces a PDF and needs outputPath: the file path to write it to.`);
246
+ const pdfSource = sniffContentType(toBytes(document)) === 'application/pdf';
247
+ if (pdfSource && HYBRID_CONVERT_TARGETS.has(input.targetFormat) && !outputPath) {
248
+ return errorResult(`A PDF source converted to ${input.targetFormat} can come back as a PDF and needs outputPath: the file path to write it to.`);
258
249
  }
259
250
  let result;
260
251
  try {
@@ -266,12 +257,14 @@ export async function runConvert(input, deps) {
266
257
  });
267
258
  }
268
259
  catch (err) {
269
- if (err instanceof BeliqApiError) {
270
- const code = err.code ? ` (${err.code})` : '';
271
- return errorResult(`beliq API error ${err.status}${code}: ${err.message}`);
272
- }
260
+ if (err instanceof BeliqApiError)
261
+ return apiErrorResult(err);
273
262
  throw err;
274
263
  }
264
+ const output = result.contentType.includes('pdf') ? 'pdf' : 'xml';
265
+ if (output === 'pdf' && !outputPath) {
266
+ return errorResult('The API returned a PDF, which needs outputPath: the file path to write it to.');
267
+ }
275
268
  const xml = output === 'xml' ? new TextDecoder().decode(result.bytes) : undefined;
276
269
  const targetFormat = result.meta.targetFormat ?? input.targetFormat;
277
270
  let bytesWritten;
@@ -307,6 +300,8 @@ export async function runConvert(input, deps) {
307
300
  profileDetected: result.meta.profileDetected,
308
301
  lostElementsCount: result.meta.lostElementsCount,
309
302
  lostElements: result.meta.lostElements,
303
+ conversionTools: result.meta.conversionTools,
304
+ livemode: result.meta.livemode,
310
305
  outputPath,
311
306
  bytesWritten,
312
307
  xml,
@@ -1,10 +1,13 @@
1
1
  import { validateInputShape, validateOutputShape } from '../schema.js';
2
2
  import { runValidate } from './shared.js';
3
3
  const description = [
4
- 'Validate an EU electronic invoice against authority-pinned, drift-checked rules and report whether it is compliant.',
5
- 'Accepts a UBL or CII XML document (inline via document, or a file via documentPath), or a Factur-X / ZUGFeRD PDF via documentPath.',
6
- 'Returns the verdict (valid or not), the detected format and profile, the ruleset (Schematron) version it was checked against, and every error and warning with its rule id, severity, location, and message.',
7
- 'beliq validates the compliant document; transmission (Peppol, PDP, KSeF, SDI), archiving, and tax-authority reporting stay with your access point.',
4
+ 'Validate an EU electronic invoice and report the verdict together with how deep the check went.',
5
+ 'Accepts an invoice XML document (inline via document, or a file via documentPath), or a Factur-X / ZUGFeRD PDF via documentPath. For a PDF the verdict is about the embedded XML: the PDF itself is not checked, and pdfInput says which embedded file was read.',
6
+ 'Returns valid, the detected format and profile, the check depth in verificationBadgeLabel, the ruleset versions the check ran, and every error and warning with its rule id, severity, location, and message.',
7
+ 'valid means the document passed the rule packs and schemas beliq runs for its format, in the versions the result names. It does not mean every recipient, validation tool or tax authority accepts it.',
8
+ 'Report the check depth beside the verdict, in the words of verificationBadgeLabel. When that field is absent, say the check depth was not stated and name none.',
9
+ 'When the result carries franceCtcBlockingRuleIds, lead with them: the API reports those French CTC rule ids as ending a transmission through a French platform, also where valid is true.',
10
+ 'beliq validates the document; transmission (Peppol, PDP, KSeF, SDI), archiving, and tax-authority reporting stay with your access point.',
8
11
  ].join(' ');
9
12
  export function registerValidate(server, deps) {
10
13
  server.registerTool('beliq_validate_einvoice', {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Printed where the API sent no badge label for a verdict. It names no depth,
3
+ * so it claims none.
4
+ */
5
+ export const DEPTH_NOT_STATED = 'check depth not stated';
6
+ /** The API's own entry in `warnings` that explains `franceCtcBlockingRuleIds`. */
7
+ const FRANCE_BLOCK_ENTRY = 'FRANCE_CTC_FINDINGS_BLOCK_TRANSMISSION';
8
+ /**
9
+ * The verdict in the API's own words. `verified: false` means no ruleset ran
10
+ * (generate with verify off), so neither word applies.
11
+ */
12
+ export function verdictWord(result) {
13
+ if (result.verified === false)
14
+ return 'NOT VALIDATED';
15
+ return result.valid ? 'VALID' : 'INVALID';
16
+ }
17
+ /** The badge label the API sent, printed as it came, or the mark for none. */
18
+ export function checkDepth(result) {
19
+ return result.verificationBadgeLabel ?? DEPTH_NOT_STATED;
20
+ }
21
+ /** The France CTC rule ids the API reports as ending a transmission; empty when it sent none. */
22
+ export function franceBlockingRuleIds(result) {
23
+ return result.franceCtcBlockingRuleIds ?? [];
24
+ }
25
+ /**
26
+ * The lines that explain a France CTC block: the API's own message for it when
27
+ * the result carries one, and the rule ids unless that message already names
28
+ * every one of them.
29
+ */
30
+ export function franceBlockLines(result) {
31
+ const ids = franceBlockingRuleIds(result);
32
+ if (ids.length === 0)
33
+ return [];
34
+ const entry = (result.warnings ?? []).find((w) => w.ruleId === FRANCE_BLOCK_ENTRY);
35
+ const idLine = `France CTC blocking rule ids: ${ids.join(', ')}`;
36
+ if (!entry)
37
+ return [idLine];
38
+ const named = new Set(entry.message.split(/[^A-Za-z0-9_-]+/));
39
+ return ids.every((id) => named.has(id)) ? [entry.message] : [entry.message, idLine];
40
+ }
41
+ /**
42
+ * The embedded file's name comes from the uploaded PDF. Control and format
43
+ * characters, line separators and the double quote are replaced, so a crafted
44
+ * name can neither start a line of its own nor close the quotes around it.
45
+ */
46
+ function printable(name) {
47
+ return name.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}"]/gu, '?');
48
+ }
49
+ /** What the API says about a PDF input: which embedded file was read, and that the PDF was not checked. */
50
+ export function pdfInputLine(result) {
51
+ const pdf = result.pdfInput;
52
+ if (!pdf)
53
+ return undefined;
54
+ const name = pdf.attachmentName ? ` "${printable(pdf.attachmentName)}"` : '';
55
+ const others = pdf.attachmentCandidates > 1
56
+ ? ` ${pdf.attachmentCandidates} embedded files could be taken for the invoice, and nothing compared them.`
57
+ : '';
58
+ return (`PDF input: the verdict is about the embedded XML${name} (found by ${pdf.attachmentFoundBy}). ` +
59
+ `The PDF itself was not checked.${others}`);
60
+ }
61
+ /** Which ruleset channel the API says served the request, when the result names one. */
62
+ export function rulesetChannelLine(result) {
63
+ if (!result.rulesetChannel)
64
+ return undefined;
65
+ const fellBack = result.rulesetFellBack ? ', which had nothing to reach, so the latest ruleset ran' : '';
66
+ const sunset = result.rulesetSunsetsOn ? `; the retained ruleset is served until ${result.rulesetSunsetsOn}` : '';
67
+ return `Ruleset channel: ${result.rulesetChannel}${fellBack}${sunset}`;
68
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "beliq-mcp",
3
- "version": "0.3.3",
4
- "description": "beliq MCP server: validate, parse, generate, and convert EU e-invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS) against authority-pinned, drift-checked rules, for MCP clients.",
3
+ "version": "0.4.0",
4
+ "description": "beliq MCP server: validate, parse, generate, and convert EU e-invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS) for MCP clients. Every verdict states how deep its check went.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "author": {
@@ -47,25 +47,26 @@
47
47
  "build": "npm run clean && tsc",
48
48
  "dev": "tsc --watch",
49
49
  "start": "node dist/index.js",
50
- "typecheck": "tsc --noEmit",
51
- "lint": "eslint src",
52
- "lint:fix": "eslint src --fix",
50
+ "typecheck": "tsc -p tsconfig.test.json",
51
+ "lint": "eslint . --max-warnings 0",
52
+ "lint:fix": "eslint . --fix",
53
53
  "test": "vitest run --exclude test/integration.test.ts",
54
54
  "test:integration": "vitest run test/integration.test.ts",
55
55
  "scrub:check": "bash scripts/scrub-check.sh",
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@beliq/sdk": "^0.4.0",
59
+ "@beliq/sdk": "^0.5.1",
60
60
  "@modelcontextprotocol/sdk": "^1.29.0",
61
61
  "zod": "^4.4.3"
62
62
  },
63
63
  "devDependencies": {
64
+ "@eslint/js": "^10.0.1",
64
65
  "@types/node": "^20.14.0",
65
- "@typescript-eslint/eslint-plugin": "^8.0.0",
66
- "@typescript-eslint/parser": "^8.0.0",
67
- "eslint": "^8.57.0",
66
+ "eslint": "^10.11.0",
67
+ "globals": "^17.12.0",
68
68
  "typescript": "^5.5.4",
69
+ "typescript-eslint": "^8.70.1",
69
70
  "vitest": "^4.0.0"
70
71
  }
71
72
  }