beliq-mcp 0.3.2 → 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 +52 -18
- package/dist/schema.js +129 -33
- package/dist/summary.js +65 -25
- package/dist/tools/convert.js +4 -3
- package/dist/tools/generate.js +5 -4
- package/dist/tools/parse.js +2 -2
- package/dist/tools/shared.js +54 -59
- package/dist/tools/validate.js +7 -4
- package/dist/verdict.js +68 -0
- package/package.json +10 -9
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
|
|
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
|
|
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
|
|
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
|
|
11
|
-
- **`beliq_generate_einvoice`** - generate a
|
|
12
|
-
- **`beliq_convert_einvoice`** - convert a document from one EN 16931 format to another
|
|
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,22 +64,56 @@ 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
|
|
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
|
|
70
|
-
- `
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
77
|
+
|
|
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.
|
|
85
|
+
|
|
86
|
+
`beliq_generate_einvoice` returns a short text summary (with the XML document appended for XML output) plus a structured result:
|
|
87
|
+
|
|
88
|
+
- `output` is `xml` or `pdf`, as requested, and `contentType` is the matching media type (`application/xml` or `application/pdf`).
|
|
89
|
+
- `xml` is the generated document inline, present only for XML output.
|
|
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.
|
|
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).
|
|
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.
|
|
94
|
+
- `sha256` is the lowercase-hex SHA-256 of the returned document bytes, so `sha256sum` on the file at `outputPath` reproduces it.
|
|
95
|
+
- `rulesetSha256` is one combined fingerprint of the rule artifacts the document was checked against, present when a ruleset ran.
|
|
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.
|
|
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`.
|
|
98
|
+
|
|
99
|
+
`beliq_convert_einvoice` returns a short text summary (with the XML document appended for an XML target) plus a structured result:
|
|
100
|
+
|
|
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.
|
|
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).
|
|
105
|
+
- `profileDetected` is the profile the engine recognised on the source document, when it recognised one.
|
|
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.
|
|
77
111
|
|
|
78
112
|
A PDF (Factur-X / ZUGFeRD) must be passed by `documentPath` for validate, parse, and convert, not inlined as text.
|
|
79
113
|
|
|
80
114
|
## Agent skill
|
|
81
115
|
|
|
82
|
-
[`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.
|
|
83
117
|
|
|
84
118
|
## Development
|
|
85
119
|
|
|
@@ -87,8 +121,8 @@ This server depends on the published [`@beliq/sdk`](https://www.npmjs.com/packag
|
|
|
87
121
|
|
|
88
122
|
- `npm install`
|
|
89
123
|
- `npm run build` - compile to `dist/`
|
|
90
|
-
- `npm run typecheck`
|
|
91
|
-
- `npm run lint`
|
|
124
|
+
- `npm run typecheck` - src and tests
|
|
125
|
+
- `npm run lint` - ESLint with type information, whole repository
|
|
92
126
|
- `npm test` - unit tests (result summary) and an in-memory MCP round-trip with a fake SDK client
|
|
93
127
|
- `BELIQ_API_KEY=your-key npm run test:integration` - live smoke tests against the real API
|
|
94
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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:
|
|
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(
|
|
21
|
+
.enum(LIVE_VALIDATE_FORMATS)
|
|
20
22
|
.optional()
|
|
21
|
-
.describe("Source syntax hint. 'auto' (default) detects
|
|
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('
|
|
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
|
-
|
|
34
|
-
|
|
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:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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,26 +248,54 @@ export const convertInputShape = {
|
|
|
180
248
|
dropFranceCtcOverlay: z
|
|
181
249
|
.boolean()
|
|
182
250
|
.optional()
|
|
183
|
-
.describe('Drop the French CTC
|
|
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
|
|
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
|
};
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
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 [
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
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.
|
|
97
|
+
? ` Document sha256 ${g.sha256}${g.verdict === undefined ? '' : `; verdict ${verdictWord(g.verdict)}`}.`
|
|
62
98
|
: '';
|
|
63
|
-
const header =
|
|
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,
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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')}
|
|
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}`;
|
package/dist/tools/convert.js
CHANGED
|
@@ -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
|
|
6
|
-
'
|
|
7
|
-
'
|
|
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) {
|
package/dist/tools/generate.js
CHANGED
|
@@ -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
|
|
5
|
-
'Set standard to the target
|
|
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
|
|
8
|
-
'beliq produces the
|
|
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', {
|
package/dist/tools/parse.js
CHANGED
|
@@ -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
|
|
7
|
-
'Use it to read the contents of an invoice; use validate to check
|
|
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', {
|
package/dist/tools/shared.js
CHANGED
|
@@ -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
|
-
/**
|
|
14
|
-
const
|
|
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
|
|
33
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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,
|
package/dist/tools/validate.js
CHANGED
|
@@ -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
|
|
5
|
-
'Accepts
|
|
6
|
-
'Returns
|
|
7
|
-
'
|
|
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', {
|
package/dist/verdict.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "beliq MCP server: validate, parse, generate, and convert EU e-invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS)
|
|
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
|
|
51
|
-
"lint": "eslint
|
|
52
|
-
"lint:fix": "eslint
|
|
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.
|
|
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
|
-
"
|
|
66
|
-
"
|
|
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
|
}
|