@vantageos/mcp-boilerplate 0.4.0 → 0.6.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -3
- package/README.fr.md +74 -5
- package/README.md +69 -5
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -2
- package/dist/http.js +7 -3
- package/dist/httpApp.d.ts +12 -0
- package/dist/httpApp.js +20 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/lake/convex.d.ts +2 -0
- package/dist/lake/convex.js +8 -3
- package/dist/locales/en.json +2 -1
- package/dist/locales/fr.json +2 -1
- package/dist/mcpServerFactory.d.ts +7 -0
- package/dist/mcpServerFactory.js +1 -1
- package/dist/pagination.d.ts +13 -14
- package/dist/pagination.js +28 -25
- package/dist/settings.d.ts +72 -0
- package/dist/settings.js +53 -0
- package/dist/signedCursor.d.ts +48 -0
- package/dist/signedCursor.js +98 -0
- package/dist/upstream/clientCredentials.d.ts +2 -0
- package/dist/upstream/clientCredentials.js +10 -4
- package/dist/upstream/paginatedClient.d.ts +22 -10
- package/dist/upstream/paginatedClient.js +51 -33
- package/dist/upstream/timeout.d.ts +26 -0
- package/dist/upstream/timeout.js +37 -0
- package/examples/oauth.env.example +7 -0
- package/mcp-server.json +1 -1
- package/package.json +5 -3
- package/plugin.json +1 -1
- package/scripts/readme-fr.mjs +150 -0
|
@@ -11,15 +11,20 @@
|
|
|
11
11
|
* `UpstreamPageError` (`row_ceiling_exceeded`) naming the ceiling. A truncated set is never
|
|
12
12
|
* returned as if it were complete.
|
|
13
13
|
* - `readPage(path, params, cursor?)` reads ONE upstream page per MCP call and returns
|
|
14
|
-
* `{ rows, nextCursor?, exhausted }`. The cursor
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
14
|
+
* `{ rows, nextCursor?, exhausted }`. The cursor carries a version, the upstream offset and a
|
|
15
|
+
* fingerprint of path + params, and it is SIGNED (src/signedCursor.ts): the token is the
|
|
16
|
+
* base64url payload and its HMAC-SHA256 tag under the client's `cursorSigner` key. The HMAC is
|
|
17
|
+
* the guard; the fingerprint only binds the cursor to its request INSIDE the signed payload.
|
|
18
|
+
* What it guarantees: a cursor this client did not issue (an edited offset, a fingerprint
|
|
19
|
+
* recomputed for other params, a token minted from the public format, one signed under another
|
|
20
|
+
* key) is refused with the `invalid_cursor` semantics of `src/pagination.ts`
|
|
21
|
+
* (`InvalidCursorError`) BEFORE any upstream request, never read as page one; and an issued
|
|
22
|
+
* cursor replayed against another path or other params is refused the same way. The ceiling
|
|
23
|
+
* check on the decoded offset stays, as defence in depth for whoever holds the key.
|
|
24
|
+
* Declared limit: the default signer's key is 32 random bytes drawn once per client, so a
|
|
25
|
+
* process restart invalidates every outstanding cursor (the caller starts again without one),
|
|
26
|
+
* and instances of a multi-instance deployment reject each other's cursors unless they are
|
|
27
|
+
* given one shared key (`cursorSigner: createCursorSigner({ key })`, 32 bytes or more).
|
|
23
28
|
*
|
|
24
29
|
* ```ts
|
|
25
30
|
* // src/upstream.ts — evaluated once per process, beside the token source
|
|
@@ -61,7 +66,8 @@
|
|
|
61
66
|
* Errors are operator-facing (logged server-side), in English like `clientCredentials.ts`.
|
|
62
67
|
*/
|
|
63
68
|
import { createHash } from "node:crypto";
|
|
64
|
-
import { InvalidCursorError } from "../
|
|
69
|
+
import { InvalidCursorError, createCursorSigner } from "../signedCursor.js";
|
|
70
|
+
import { boundedFetch, configuredUpstreamTimeoutMs, upstreamSignal } from "./timeout.js";
|
|
65
71
|
/** A refused or failed upstream page read. Carries no header, token, parameter or body. */
|
|
66
72
|
export class UpstreamPageError extends Error {
|
|
67
73
|
code;
|
|
@@ -87,14 +93,14 @@ const DEFAULT_PAGE_SIZE = 100;
|
|
|
87
93
|
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
88
94
|
const DEFAULT_BASE_DELAY_MS = 500;
|
|
89
95
|
const DEFAULT_MAX_DELAY_MS = 30_000;
|
|
90
|
-
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
91
96
|
const RETRYABLE = new Set([429, 503]);
|
|
92
97
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
93
98
|
/**
|
|
94
|
-
* Cursor format. It mirrors `src/pagination.ts` (
|
|
99
|
+
* Cursor format. It mirrors `src/pagination.ts` (a signed JSON payload with a version, refused as
|
|
95
100
|
* `InvalidCursorError`) but cannot reuse its `encodeCursor`/`decodeCursor`: those carry one
|
|
96
101
|
* string key (`after`), and this cursor carries a numeric offset plus a fingerprint. The version
|
|
97
|
-
* differs (2) so a `list_items` cursor is never read as an upstream cursor, nor the reverse
|
|
102
|
+
* differs (2) so a `list_items` cursor is never read as an upstream cursor, nor the reverse, even
|
|
103
|
+
* when both are signed under one shared key.
|
|
98
104
|
*/
|
|
99
105
|
const CURSOR_VERSION = 2;
|
|
100
106
|
const isPositiveInteger = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0;
|
|
@@ -134,27 +140,29 @@ function fingerprint(path, params) {
|
|
|
134
140
|
.subarray(0, 16)
|
|
135
141
|
.toString("base64url");
|
|
136
142
|
}
|
|
137
|
-
function encodeOffsetCursor(offset, print) {
|
|
138
|
-
return
|
|
143
|
+
function encodeOffsetCursor(signer, offset, print) {
|
|
144
|
+
return signer.sign(JSON.stringify({ v: CURSOR_VERSION, o: offset, f: print }));
|
|
139
145
|
}
|
|
140
|
-
|
|
146
|
+
/** The offset of a cursor this client issued for this request, or `InvalidCursorError`. */
|
|
147
|
+
function decodeOffsetCursor(signer, cursor, print) {
|
|
148
|
+
// Verified first: nothing in an unsigned or edited token is read.
|
|
149
|
+
const payload = signer.verify(cursor);
|
|
141
150
|
let decoded;
|
|
142
151
|
try {
|
|
143
|
-
decoded = JSON.parse(
|
|
152
|
+
decoded = JSON.parse(payload);
|
|
144
153
|
}
|
|
145
154
|
catch {
|
|
146
|
-
throw new InvalidCursorError();
|
|
155
|
+
throw new InvalidCursorError("malformed_payload");
|
|
147
156
|
}
|
|
148
|
-
if (typeof decoded !== "object" || decoded === null)
|
|
149
|
-
throw new InvalidCursorError();
|
|
150
|
-
const { v, o, f } = decoded;
|
|
151
|
-
if (v !== CURSOR_VERSION || !isPositiveInteger(o) || f !== print) {
|
|
152
|
-
throw new InvalidCursorError();
|
|
157
|
+
if (typeof decoded !== "object" || decoded === null) {
|
|
158
|
+
throw new InvalidCursorError("malformed_payload");
|
|
153
159
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
const { v, o } = decoded;
|
|
161
|
+
if (v !== CURSOR_VERSION || !isPositiveInteger(o)) {
|
|
162
|
+
throw new InvalidCursorError("malformed_payload");
|
|
163
|
+
}
|
|
164
|
+
if (decoded.f !== print)
|
|
165
|
+
throw new InvalidCursorError("other_request");
|
|
158
166
|
return o;
|
|
159
167
|
}
|
|
160
168
|
/** `Retry-After` (RFC 9110 §10.2.3) in ms, from delay-seconds or an HTTP date; else undefined. */
|
|
@@ -173,7 +181,12 @@ function isScalar(value) {
|
|
|
173
181
|
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
174
182
|
}
|
|
175
183
|
export function createPaginatedUpstreamClient(options) {
|
|
176
|
-
const { fetchImpl =
|
|
184
|
+
const { fetchImpl: rawFetch = fetch, baseUrl, allowedOrigins, offsetParam, limitParam, placement, pageSize = DEFAULT_PAGE_SIZE, rowCeiling, extractRows, extractTotal, headers: extraHeaders = {}, maxAttempts = DEFAULT_MAX_ATTEMPTS, baseDelayMs = DEFAULT_BASE_DELAY_MS, maxDelayMs = DEFAULT_MAX_DELAY_MS, timeoutMs = configuredUpstreamTimeoutMs(), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), random = Math.random, now = Date.now, cursorSigner = createCursorSigner(), } = options;
|
|
185
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
186
|
+
throw new Error("paginated upstream: timeoutMs must be a positive number");
|
|
187
|
+
}
|
|
188
|
+
// S-13: every page request, whatever fetch it goes through, carries the timeout.
|
|
189
|
+
const fetchImpl = boundedFetch(rawFetch, timeoutMs);
|
|
177
190
|
if (!Array.isArray(allowedOrigins) || allowedOrigins.length === 0) {
|
|
178
191
|
throw new Error("paginated upstream: allowedOrigins is required and must not be empty");
|
|
179
192
|
}
|
|
@@ -224,8 +237,11 @@ export function createPaginatedUpstreamClient(options) {
|
|
|
224
237
|
throw new Error(`paginated upstream: ${name} must be a non-negative number`);
|
|
225
238
|
}
|
|
226
239
|
}
|
|
227
|
-
if (
|
|
228
|
-
|
|
240
|
+
if (cursorSigner === null ||
|
|
241
|
+
typeof cursorSigner !== "object" ||
|
|
242
|
+
typeof cursorSigner.sign !== "function" ||
|
|
243
|
+
typeof cursorSigner.verify !== "function") {
|
|
244
|
+
throw new Error("paginated upstream: cursorSigner must be a signer from createCursorSigner");
|
|
229
245
|
}
|
|
230
246
|
/** Resolves the path and refuses, before any network call, an origin not allowed. */
|
|
231
247
|
const resolveUrl = (path) => {
|
|
@@ -303,7 +319,9 @@ export function createPaginatedUpstreamClient(options) {
|
|
|
303
319
|
body,
|
|
304
320
|
// A redirect is refused, never followed: the origin check above must stay total.
|
|
305
321
|
redirect: "error",
|
|
306
|
-
|
|
322
|
+
// Explicit at the call so the budget is readable (and statically checkable) here;
|
|
323
|
+
// `boundedFetch` above bounds every other path to `fetchImpl` the same way.
|
|
324
|
+
signal: upstreamSignal(timeoutMs),
|
|
307
325
|
});
|
|
308
326
|
}
|
|
309
327
|
catch (err) {
|
|
@@ -401,7 +419,7 @@ export function createPaginatedUpstreamClient(options) {
|
|
|
401
419
|
const url = resolveUrl(path);
|
|
402
420
|
checkParams(params, url.pathname);
|
|
403
421
|
const print = fingerprint(url.pathname + url.search, params);
|
|
404
|
-
const offset = cursor === undefined ? 0 : decodeOffsetCursor(cursor, print);
|
|
422
|
+
const offset = cursor === undefined ? 0 : decodeOffsetCursor(cursorSigner, cursor, print);
|
|
405
423
|
// A cursor at the ceiling may still meet an empty page (exact fit); beyond it, nothing is read.
|
|
406
424
|
if (offset > rowCeiling)
|
|
407
425
|
throw ceilingError(url.pathname);
|
|
@@ -415,7 +433,7 @@ export function createPaginatedUpstreamClient(options) {
|
|
|
415
433
|
page.exhausted = true;
|
|
416
434
|
return page;
|
|
417
435
|
}
|
|
418
|
-
page.nextCursor = encodeOffsetCursor(offset + rows.length, print);
|
|
436
|
+
page.nextCursor = encodeOffsetCursor(cursorSigner, offset + rows.length, print);
|
|
419
437
|
return page;
|
|
420
438
|
};
|
|
421
439
|
return { readAll, readPage };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one mechanism behind S-13 ("every request the server emits carries a configured timeout").
|
|
3
|
+
*
|
|
4
|
+
* CENSUS of outbound call sites in src/ (`rg -n '\bfetch\(|fetchImpl\(|undici|http\.request|https\.request' src`,
|
|
5
|
+
* plus the one site that hands `fetch` to a library, `ConvexHttpClient`). Each carries an
|
|
6
|
+
* AbortSignal timeout:
|
|
7
|
+
* upstream/paginatedClient.ts page request (fetchImpl, default global fetch) boundedFetch, UPSTREAM_TIMEOUT_MS
|
|
8
|
+
* upstream/clientCredentials.ts token request boundedFetch, option timeoutMs (10 s)
|
|
9
|
+
* upstream/clientCredentials.ts fetchWithToken (API request) boundedFetch, UPSTREAM_TIMEOUT_MS
|
|
10
|
+
* oauth/clerk.ts Backend API + JWKS (2 sites) per-call + total deadline (own signal)
|
|
11
|
+
* oauth/clientRegistry.ts CIMD metadata fetch own signal, option timeoutMs (5 s)
|
|
12
|
+
* config.ts external-issuer JWKS fetch upstreamSignal, UPSTREAM_TIMEOUT_MS
|
|
13
|
+
* lake/convex.ts ConvexHttpClient `fetch` option boundedFetch, UPSTREAM_TIMEOUT_MS
|
|
14
|
+
* src/nodeAdapter.ts and src/httpApp.ts `.fetch(` are the INBOUND handler, not a request emitted.
|
|
15
|
+
* A new outbound site belongs in this list and uses `boundedFetch` or `upstreamSignal`.
|
|
16
|
+
*/
|
|
17
|
+
/** The configured budget: `UPSTREAM_TIMEOUT_MS`, validated at read (ConfigurationError if bad). */
|
|
18
|
+
export declare function configuredUpstreamTimeoutMs(env?: NodeJS.ProcessEnv): number;
|
|
19
|
+
/** A timeout signal, combined with the caller's own signal when there is one (first to fire wins). */
|
|
20
|
+
export declare function upstreamSignal(timeoutMs: number, callerSignal?: AbortSignal | null): AbortSignal;
|
|
21
|
+
/**
|
|
22
|
+
* `fetchImpl` such that EVERY call carries the timeout, whatever the caller passed. A caller
|
|
23
|
+
* signal (in `init`, or on a `Request` input) still applies: the earlier of the two aborts.
|
|
24
|
+
* The abort surfaces as the fetch's own rejection (`TimeoutError`), for the call site to type.
|
|
25
|
+
*/
|
|
26
|
+
export declare function boundedFetch(fetchImpl: typeof fetch, timeoutMs: number): typeof fetch;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one mechanism behind S-13 ("every request the server emits carries a configured timeout").
|
|
3
|
+
*
|
|
4
|
+
* CENSUS of outbound call sites in src/ (`rg -n '\bfetch\(|fetchImpl\(|undici|http\.request|https\.request' src`,
|
|
5
|
+
* plus the one site that hands `fetch` to a library, `ConvexHttpClient`). Each carries an
|
|
6
|
+
* AbortSignal timeout:
|
|
7
|
+
* upstream/paginatedClient.ts page request (fetchImpl, default global fetch) boundedFetch, UPSTREAM_TIMEOUT_MS
|
|
8
|
+
* upstream/clientCredentials.ts token request boundedFetch, option timeoutMs (10 s)
|
|
9
|
+
* upstream/clientCredentials.ts fetchWithToken (API request) boundedFetch, UPSTREAM_TIMEOUT_MS
|
|
10
|
+
* oauth/clerk.ts Backend API + JWKS (2 sites) per-call + total deadline (own signal)
|
|
11
|
+
* oauth/clientRegistry.ts CIMD metadata fetch own signal, option timeoutMs (5 s)
|
|
12
|
+
* config.ts external-issuer JWKS fetch upstreamSignal, UPSTREAM_TIMEOUT_MS
|
|
13
|
+
* lake/convex.ts ConvexHttpClient `fetch` option boundedFetch, UPSTREAM_TIMEOUT_MS
|
|
14
|
+
* src/nodeAdapter.ts and src/httpApp.ts `.fetch(` are the INBOUND handler, not a request emitted.
|
|
15
|
+
* A new outbound site belongs in this list and uses `boundedFetch` or `upstreamSignal`.
|
|
16
|
+
*/
|
|
17
|
+
import { UPSTREAM_TIMEOUT_DEFAULT_MS, loadUpstreamSettings } from "../settings.js";
|
|
18
|
+
/** The configured budget: `UPSTREAM_TIMEOUT_MS`, validated at read (ConfigurationError if bad). */
|
|
19
|
+
export function configuredUpstreamTimeoutMs(env = process.env) {
|
|
20
|
+
return loadUpstreamSettings(env).upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_DEFAULT_MS;
|
|
21
|
+
}
|
|
22
|
+
/** A timeout signal, combined with the caller's own signal when there is one (first to fire wins). */
|
|
23
|
+
export function upstreamSignal(timeoutMs, callerSignal) {
|
|
24
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
25
|
+
return callerSignal ? AbortSignal.any([timeout, callerSignal]) : timeout;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `fetchImpl` such that EVERY call carries the timeout, whatever the caller passed. A caller
|
|
29
|
+
* signal (in `init`, or on a `Request` input) still applies: the earlier of the two aborts.
|
|
30
|
+
* The abort surfaces as the fetch's own rejection (`TimeoutError`), for the call site to type.
|
|
31
|
+
*/
|
|
32
|
+
export function boundedFetch(fetchImpl, timeoutMs) {
|
|
33
|
+
return ((input, init) => {
|
|
34
|
+
const caller = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
35
|
+
return fetchImpl(input, { ...init, signal: upstreamSignal(timeoutMs, caller) });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -38,5 +38,12 @@ MCP_OAUTH_SIGNING_JWK=
|
|
|
38
38
|
# MCP_MAX_BODY_BYTES=1048576
|
|
39
39
|
# Browser origins allowed to call /register cross-origin (comma-separated; default none).
|
|
40
40
|
# MCP_REGISTER_CORS_ORIGINS=https://inspector.example.com
|
|
41
|
+
# Browser origins allowed to call /mcp (S-22; comma-separated, scheme://host[:port]; default none =
|
|
42
|
+
# every request carrying an Origin header is refused 403; no Origin header = not a browser, passes).
|
|
43
|
+
# A wildcard or a path stops the server at start. /register keeps its own list above.
|
|
44
|
+
# MCP_ALLOWED_ORIGINS=https://claude.ai
|
|
45
|
+
# Time budget of one outbound request to an upstream (S-13; integer milliseconds, 1000 to 300000,
|
|
46
|
+
# default 30000). A value that is not an integer or is out of range stops the server at start.
|
|
47
|
+
# UPSTREAM_TIMEOUT_MS=30000
|
|
41
48
|
# Display name shown above the consent page title (default: none).
|
|
42
49
|
# AUTHORIZE_BRAND_NAME=Acme MCP
|
package/mcp-server.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vantageos/mcp-boilerplate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Clonable MCP server skeleton on the official @modelcontextprotocol/server SDK v2 (protocol 2026-07-28 + 2025 legacy, stdio + stateless Streamable HTTP), with its own OAuth 2.1 authorization server (Clerk person + organisation, cloud-identity), MCP Apps UI, tenant scoping, FR/EN i18n and a declarative eval harness.",
|
|
5
5
|
"description_fr": "Squelette de serveur MCP clonable sur le SDK officiel @modelcontextprotocol/server v2 (protocole 2026-07-28 + héritage 2025, stdio + Streamable HTTP sans état), avec son propre serveur d'autorisation OAuth 2.1 (personne + organisation Clerk, cloud-identity), UI MCP Apps, scoping tenant, i18n FR/EN et un harnais d'évaluations déclaratif.",
|
|
6
6
|
"type": "module",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"./package.json": "./package.json"
|
|
19
19
|
},
|
|
20
20
|
"bin": {
|
|
21
|
-
"mcp-boilerplate": "./dist/stdio.js"
|
|
21
|
+
"mcp-boilerplate": "./dist/stdio.js",
|
|
22
|
+
"mcp-readme-fr": "./scripts/readme-fr.mjs"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
24
25
|
"dist",
|
|
@@ -36,7 +37,8 @@
|
|
|
36
37
|
"scripts/generate-signing-key.mjs",
|
|
37
38
|
"docs",
|
|
38
39
|
"examples/claude-desktop.json",
|
|
39
|
-
"examples/oauth.env.example"
|
|
40
|
+
"examples/oauth.env.example",
|
|
41
|
+
"scripts/readme-fr.mjs"
|
|
40
42
|
],
|
|
41
43
|
"publishConfig": {
|
|
42
44
|
"access": "public"
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "vantage-mcp-boilerplate",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"description": "Clonable MCP server template: SDK v2 on protocol 2026-07-28 (stdio + stateless Streamable HTTP), its own OAuth 2.1 authorization server (Clerk person + organisation), MCP Apps UI, FR/EN i18n and a declarative eval harness. Scaffold, extend, publish.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ElPi Corp / Laurent Perello"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* readme-fr — generate README.fr.md from a bilingual README.md (mcp-standard C-1: a French reader gets one French
|
|
4
|
+
* file). The French text is written once, in README.md, next to the English; README.fr.md is derived, never
|
|
5
|
+
* hand-copied, and `--check` fails when the two diverge.
|
|
6
|
+
*
|
|
7
|
+
* The language of a passage is read from an explicit marker, never guessed from its words. Census of the forms:
|
|
8
|
+
* <!-- lang:en --> opens a bilingual region; the lines that follow are its English part (dropped)
|
|
9
|
+
* <!-- lang:fr --> switches the open region to its French part (kept)
|
|
10
|
+
* <!-- /lang --> closes the region
|
|
11
|
+
* A marker stands alone on its line. Text outside a region is shared and copied as is (code blocks, tables, links).
|
|
12
|
+
* A fenced code block (``` or ~~~) is copied verbatim with the part it sits in; a marker inside it is text. An
|
|
13
|
+
* unclosed fence is an error naming its opening line.
|
|
14
|
+
* Errors, each naming its line (exit 2): an unknown language, a nested region, a French part before the English one,
|
|
15
|
+
* a region closed with no French part, a close with no open region, a region left open at the end of the file.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* mcp-readme-fr --check [--dir <path>] regenerate in memory; exit 1 naming the first differing line
|
|
19
|
+
* mcp-readme-fr --write [--dir <path>] write README.fr.md; refuses (exit 2) to overwrite a README.fr.md that
|
|
20
|
+
* does not start with the generated-file banner (a hand translation)
|
|
21
|
+
* No dependencies: node built-ins only.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
export const BANNER =
|
|
28
|
+
"<!-- Généré depuis README.md par mcp-readme-fr (@vantageos/mcp-boilerplate) : ne pas modifier à la main. -->";
|
|
29
|
+
|
|
30
|
+
const MARKER = /^\s*<!--\s*(lang:([A-Za-z-]+)|\/lang)\s*-->\s*$/;
|
|
31
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
32
|
+
|
|
33
|
+
/** README.md text -> { fr, errors }. `fr` is the README.fr.md content (banner first). */
|
|
34
|
+
export function generate(text) {
|
|
35
|
+
const out = [BANNER, ""];
|
|
36
|
+
const errors = [];
|
|
37
|
+
let state = "shared"; // shared | en | fr
|
|
38
|
+
let openedAt = 0;
|
|
39
|
+
const lines = text.split("\n");
|
|
40
|
+
let fence = null; // { char, len, at } while inside a fenced code block
|
|
41
|
+
lines.forEach((line, i) => {
|
|
42
|
+
const n = i + 1;
|
|
43
|
+
// A fenced code block (``` or ~~~, CommonMark: up to 3 spaces of indent) is copied verbatim with the part it
|
|
44
|
+
// sits in: a marker inside it is text, never a region.
|
|
45
|
+
const f = FENCE.exec(line);
|
|
46
|
+
if (fence !== null) {
|
|
47
|
+
if (state !== "en") out.push(line);
|
|
48
|
+
if (f && f[1][0] === fence.char && f[1].length >= fence.len && f[2].trim() === "")
|
|
49
|
+
fence = null;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (f) {
|
|
53
|
+
fence = { char: f[1][0], len: f[1].length, at: n };
|
|
54
|
+
if (state !== "en") out.push(line);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const m = MARKER.exec(line);
|
|
58
|
+
if (!m) {
|
|
59
|
+
if (state !== "en") out.push(line);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (m[1] === "/lang") {
|
|
63
|
+
if (state === "shared") errors.push(`line ${n}: <!-- /lang --> closes no open region`);
|
|
64
|
+
else if (state === "en")
|
|
65
|
+
errors.push(
|
|
66
|
+
`line ${n}: the region opened at line ${openedAt} has no French part (<!-- lang:fr -->)`
|
|
67
|
+
);
|
|
68
|
+
state = "shared";
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const lang = m[2].toLowerCase();
|
|
72
|
+
if (lang !== "en" && lang !== "fr") {
|
|
73
|
+
errors.push(`line ${n}: unknown language marker lang:${m[2]} (known: en, fr)`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (lang === "en") {
|
|
77
|
+
if (state !== "shared")
|
|
78
|
+
errors.push(
|
|
79
|
+
`line ${n}: nested region (the region opened at line ${openedAt} is still open)`
|
|
80
|
+
);
|
|
81
|
+
state = "en";
|
|
82
|
+
openedAt = n;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// lang === "fr"
|
|
86
|
+
if (state === "shared")
|
|
87
|
+
errors.push(
|
|
88
|
+
`line ${n}: lang:fr opens a region before its English part (<!-- lang:en --> first)`
|
|
89
|
+
);
|
|
90
|
+
else if (state === "fr")
|
|
91
|
+
errors.push(`line ${n}: a second lang:fr in the region opened at line ${openedAt}`);
|
|
92
|
+
state = "fr";
|
|
93
|
+
});
|
|
94
|
+
if (fence !== null)
|
|
95
|
+
errors.push(`line ${fence.at}: unclosed code fence (${fence.char.repeat(fence.len)})`);
|
|
96
|
+
if (state !== "shared")
|
|
97
|
+
errors.push(
|
|
98
|
+
`line ${lines.length}: the region opened at line ${openedAt} is unclosed (<!-- /lang -->)`
|
|
99
|
+
);
|
|
100
|
+
return { fr: out.join("\n"), errors };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function main(argv, cwd = process.cwd()) {
|
|
104
|
+
const mode = argv.includes("--write") ? "write" : argv.includes("--check") ? "check" : null;
|
|
105
|
+
const dirAt = argv.indexOf("--dir");
|
|
106
|
+
const dir = resolve(cwd, dirAt >= 0 ? (argv[dirAt + 1] ?? ".") : ".");
|
|
107
|
+
if (mode === null) {
|
|
108
|
+
console.error("mcp-readme-fr: --check or --write is required");
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
const readmePath = join(dir, "README.md");
|
|
112
|
+
const frPath = join(dir, "README.fr.md");
|
|
113
|
+
if (!existsSync(readmePath)) {
|
|
114
|
+
console.error(`mcp-readme-fr: ${readmePath} not found`);
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
const { fr, errors } = generate(readFileSync(readmePath, "utf8"));
|
|
118
|
+
if (errors.length > 0) {
|
|
119
|
+
for (const e of errors) console.error(`README.md ${e}`);
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
const current = existsSync(frPath) ? readFileSync(frPath, "utf8") : null;
|
|
123
|
+
if (mode === "write") {
|
|
124
|
+
if (current !== null && !current.startsWith(BANNER)) {
|
|
125
|
+
console.error(
|
|
126
|
+
`mcp-readme-fr: ${frPath} was not generated (no banner): refusing to overwrite a hand translation`
|
|
127
|
+
);
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
writeFileSync(frPath, fr);
|
|
131
|
+
console.log(`mcp-readme-fr: wrote ${frPath}`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
if (current === fr) {
|
|
135
|
+
console.log("mcp-readme-fr --check: README.fr.md matches README.md");
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
const a = (current ?? "").split("\n");
|
|
139
|
+
const b = fr.split("\n");
|
|
140
|
+
let i = 0;
|
|
141
|
+
while (i < Math.max(a.length, b.length) && a[i] === b[i]) i++;
|
|
142
|
+
console.error(
|
|
143
|
+
`mcp-readme-fr --check: README.fr.md differs from what README.md generates at line ${i + 1} — run mcp-readme-fr --write`
|
|
144
|
+
);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
149
|
+
process.exit(main(process.argv.slice(2)));
|
|
150
|
+
}
|