@lekteo/mcp 0.1.2
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/LICENSE +58 -0
- package/README.de.md +103 -0
- package/README.fr.md +102 -0
- package/README.md +100 -0
- package/README.nl.md +102 -0
- package/dist/server.js +5276 -0
- package/package.json +59 -0
- package/src/server.ts +235 -0
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lekteo/mcp",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Lire une facture électronique — UBL, CII, Factur-X, ZUGFeRD, XRechnung — depuis un assistant, sans que le fichier quitte votre machine",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"e-invoice",
|
|
10
|
+
"einvoice",
|
|
11
|
+
"ubl",
|
|
12
|
+
"cii",
|
|
13
|
+
"factur-x",
|
|
14
|
+
"zugferd",
|
|
15
|
+
"xrechnung",
|
|
16
|
+
"peppol",
|
|
17
|
+
"en16931",
|
|
18
|
+
"facture-electronique"
|
|
19
|
+
],
|
|
20
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
21
|
+
"homepage": "https://lekteo.eu",
|
|
22
|
+
"bin": {
|
|
23
|
+
"lekteo-mcp": "./dist/server.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md",
|
|
30
|
+
"README.fr.md",
|
|
31
|
+
"README.de.md",
|
|
32
|
+
"README.nl.md"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "node scripts/bundle.mjs",
|
|
42
|
+
"prepublishOnly": "node scripts/bundle.mjs",
|
|
43
|
+
"lint": "eslint . --max-warnings 0",
|
|
44
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
45
|
+
"test": "vitest run --passWithNoTests"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.22.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@lekteo/config": "workspace:*",
|
|
52
|
+
"@lekteo/einvoice-core": "workspace:*",
|
|
53
|
+
"@types/node": "^22.20.1",
|
|
54
|
+
"esbuild": "^0.25.0",
|
|
55
|
+
"eslint": "^9.39.5",
|
|
56
|
+
"typescript": "~5.7.3",
|
|
57
|
+
"vitest": "^4.1.10"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lekteo as an MCP server.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists, and why it runs locally
|
|
5
|
+
*
|
|
6
|
+
* The website reads an invoice inside the browser, and anyone can verify that
|
|
7
|
+
* nothing leaves the machine by opening the network tab or unplugging the
|
|
8
|
+
* network. That verifiable promise is the product.
|
|
9
|
+
*
|
|
10
|
+
* An assistant cannot open a browser tab. Serving the same feature over a
|
|
11
|
+
* hosted endpoint would have meant uploading invoices to us — the one thing
|
|
12
|
+
* the product exists not to do. So this runs **on the user's own machine**,
|
|
13
|
+
* over stdio, launched by `npx`. The file is read from their disk, parsed in
|
|
14
|
+
* their process, and the answer goes back to their assistant.
|
|
15
|
+
*
|
|
16
|
+
* The promise is not merely preserved, it widens: it now holds outside the
|
|
17
|
+
* browser too.
|
|
18
|
+
*
|
|
19
|
+
* ## How to check that claim rather than believe it
|
|
20
|
+
*
|
|
21
|
+
* This server performs **no network access of any kind**. There is no HTTP
|
|
22
|
+
* client, no telemetry, no update check. It is verifiable without reading a
|
|
23
|
+
* line of logic:
|
|
24
|
+
*
|
|
25
|
+
* grep -c "fetch(\|https://\|http://" "$(npm root -g)/@lekteo/mcp/dist/server.js"
|
|
26
|
+
*
|
|
27
|
+
* The only addresses in the bundle are in comments and in error messages.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
31
|
+
import { basename, resolve } from 'node:path';
|
|
32
|
+
|
|
33
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
34
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
35
|
+
import {
|
|
36
|
+
CallToolRequestSchema,
|
|
37
|
+
ListToolsRequestSchema,
|
|
38
|
+
type CallToolResult,
|
|
39
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
40
|
+
|
|
41
|
+
import { readInvoiceFromBytes, toJson, type ReadResult } from '@lekteo/einvoice-core';
|
|
42
|
+
import { isSupportedLocale, renderFinding } from '@lekteo/einvoice-core/i18n';
|
|
43
|
+
|
|
44
|
+
const VERSION = '0.1.2';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Language of the sentences handed back.
|
|
48
|
+
*
|
|
49
|
+
* The findings carry codes, never prose; the wording is produced here. An
|
|
50
|
+
* assistant conversing in German should not receive French diagnostics, so
|
|
51
|
+
* the language is a parameter of every tool rather than a setting of the
|
|
52
|
+
* server — the same session may switch.
|
|
53
|
+
*/
|
|
54
|
+
const DEFAULT_LOCALE = 'fr';
|
|
55
|
+
function localeOf(value: unknown): string {
|
|
56
|
+
return typeof value === 'string' && isSupportedLocale(value) ? value : DEFAULT_LOCALE;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Refuse a path we were not given deliberately.
|
|
61
|
+
*
|
|
62
|
+
* The assistant chooses the path, not the user, and a model that has just
|
|
63
|
+
* been told "check my invoices" is entirely capable of trying `/etc/passwd`
|
|
64
|
+
* to see what happens. Reading is confined to real files, and the size guard
|
|
65
|
+
* of the engine applies unchanged.
|
|
66
|
+
*/
|
|
67
|
+
async function loadFile(path: unknown): Promise<{ bytes: Uint8Array; name: string }> {
|
|
68
|
+
if (typeof path !== 'string' || path.trim() === '') {
|
|
69
|
+
throw new Error('Le chemin du fichier est requis.');
|
|
70
|
+
}
|
|
71
|
+
const full = resolve(path);
|
|
72
|
+
const info = await stat(full).catch(() => null);
|
|
73
|
+
if (info === null) throw new Error(`Aucun fichier à cette adresse : ${full}`);
|
|
74
|
+
if (!info.isFile()) throw new Error(`Ce n'est pas un fichier : ${full}`);
|
|
75
|
+
return { bytes: new Uint8Array(await readFile(full)), name: basename(full) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The findings, turned into sentences a human can act on. */
|
|
79
|
+
function findingsOf(result: ReadResult, locale: string) {
|
|
80
|
+
return result.findings.map((finding) => {
|
|
81
|
+
const rendered = renderFinding(finding, locale);
|
|
82
|
+
return {
|
|
83
|
+
code: finding.code,
|
|
84
|
+
severity: finding.severity,
|
|
85
|
+
message: rendered.message,
|
|
86
|
+
explanation: rendered.explanation ?? null,
|
|
87
|
+
where: rendered.path ?? null,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The findings as an export wants them: the machine fields kept whole, the
|
|
94
|
+
* sentences added on top.
|
|
95
|
+
*
|
|
96
|
+
* `findingsOf` trims to what a human reads; a JSON export feeds a script,
|
|
97
|
+
* which may well need `params` and the structured path the caption is made
|
|
98
|
+
* from. Losing them to gain a sentence would be a poor trade.
|
|
99
|
+
*/
|
|
100
|
+
function findingsForExport(result: ReadResult, locale: string) {
|
|
101
|
+
return result.findings.map((finding) => {
|
|
102
|
+
const rendered = renderFinding(finding, locale);
|
|
103
|
+
return {
|
|
104
|
+
...finding,
|
|
105
|
+
message: rendered.message,
|
|
106
|
+
explanation: rendered.explanation ?? null,
|
|
107
|
+
where: rendered.path ?? null,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function summaryOf(result: ReadResult) {
|
|
113
|
+
return {
|
|
114
|
+
readable: result.ok,
|
|
115
|
+
syntax: result.syntax,
|
|
116
|
+
profile: result.profile,
|
|
117
|
+
compliant: result.summary.compliant,
|
|
118
|
+
errors: result.summary.errors,
|
|
119
|
+
warnings: result.summary.warnings,
|
|
120
|
+
infos: result.summary.infos,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const text = (value: unknown): CallToolResult => ({
|
|
125
|
+
content: [
|
|
126
|
+
{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) },
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const TOOLS = [
|
|
131
|
+
{
|
|
132
|
+
name: 'read_invoice',
|
|
133
|
+
description:
|
|
134
|
+
'Read an electronic invoice file (UBL 2.1, CII D22B, Factur-X, ZUGFeRD, XRechnung) and return it as readable data: parties, lines, VAT breakdown, totals, payment details. The file is read locally and never transmitted anywhere.',
|
|
135
|
+
inputSchema: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
path: {
|
|
139
|
+
type: 'string',
|
|
140
|
+
description: 'Absolute or relative path to the .xml or .pdf file.',
|
|
141
|
+
},
|
|
142
|
+
locale: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
enum: ['fr', 'en', 'de', 'nl'],
|
|
145
|
+
description: 'Language of the findings. Defaults to French.',
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
required: ['path'],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'check_invoice',
|
|
153
|
+
description:
|
|
154
|
+
'Check an electronic invoice against the European EN 16931 standard without returning its content. Reports whether it is compliant and lists what fails: IBAN check digits, VAT amounts against their taxable base, mandatory fields, date consistency. Use this when the question is "is this file in order".',
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties: {
|
|
158
|
+
path: {
|
|
159
|
+
type: 'string',
|
|
160
|
+
description: 'Absolute or relative path to the .xml or .pdf file.',
|
|
161
|
+
},
|
|
162
|
+
locale: { type: 'string', enum: ['fr', 'en', 'de', 'nl'] },
|
|
163
|
+
},
|
|
164
|
+
required: ['path'],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: 'invoice_to_json',
|
|
169
|
+
description:
|
|
170
|
+
'Read an invoice and return the full canonical model as JSON, findings included. Use this to feed the data into a script or a spreadsheet rather than to read it.',
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
properties: {
|
|
174
|
+
path: {
|
|
175
|
+
type: 'string',
|
|
176
|
+
description: 'Absolute or relative path to the .xml or .pdf file.',
|
|
177
|
+
},
|
|
178
|
+
locale: { type: 'string', enum: ['fr', 'en', 'de', 'nl'] },
|
|
179
|
+
},
|
|
180
|
+
required: ['path'],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const server = new Server({ name: 'lekteo', version: VERSION }, { capabilities: { tools: {} } });
|
|
186
|
+
|
|
187
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
188
|
+
|
|
189
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
190
|
+
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
|
|
191
|
+
try {
|
|
192
|
+
const { bytes, name } = await loadFile(args.path);
|
|
193
|
+
const result = readInvoiceFromBytes(bytes, name);
|
|
194
|
+
const locale = localeOf(args.locale);
|
|
195
|
+
|
|
196
|
+
switch (request.params.name) {
|
|
197
|
+
case 'read_invoice':
|
|
198
|
+
return text({
|
|
199
|
+
file: name,
|
|
200
|
+
...summaryOf(result),
|
|
201
|
+
invoice: result.invoice,
|
|
202
|
+
findings: findingsOf(result, locale),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
case 'check_invoice':
|
|
206
|
+
return text({ file: name, ...summaryOf(result), findings: findingsOf(result, locale) });
|
|
207
|
+
|
|
208
|
+
case 'invoice_to_json':
|
|
209
|
+
return text(
|
|
210
|
+
toJson({
|
|
211
|
+
syntax: result.syntax,
|
|
212
|
+
profile: result.profile,
|
|
213
|
+
invoice: result.invoice,
|
|
214
|
+
findings: findingsForExport(result, locale),
|
|
215
|
+
fileName: name,
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
default:
|
|
220
|
+
throw new Error(`Outil inconnu : ${request.params.name}`);
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
// Returned as a result rather than thrown: an unreadable file is an
|
|
224
|
+
// answer, not a crash. The assistant can relay the sentence to the user,
|
|
225
|
+
// which is the whole point of writing findings in plain language.
|
|
226
|
+
return {
|
|
227
|
+
isError: true,
|
|
228
|
+
content: [
|
|
229
|
+
{ type: 'text', text: error instanceof Error ? error.message : 'Lecture impossible.' },
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await server.connect(new StdioServerTransport());
|