@vantageos/mcp-boilerplate 0.8.0 → 0.9.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 +6 -0
- package/README.fr.md +8 -0
- package/README.md +7 -0
- package/dist/build-info.json +1 -1
- package/dist/embedded.generated.d.ts +1 -1
- package/dist/embedded.generated.js +1 -1
- package/dist/lake/convex.d.ts +4 -0
- package/dist/lake/convex.js +10 -3
- package/dist/lake/reader.d.ts +35 -2
- package/dist/lake/reader.js +113 -2
- package/mcp-server.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vantage-mcp-boilerplate",
|
|
3
3
|
"displayName": "VantageOS MCP Boilerplate",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.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"
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-11-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `LakePageRequest` takes an optional `filter` (`LakeFilterValue`, three forms: scalar equality, a list of up to 100 strings/numbers for membership, or inclusive bounds `{ gte?, lte? }` for windows, numbers or ISO date strings; flat record, at most 32 keys), applied by the adapter inside the range read like `scope` (mcp-standard S-35), never over rows after a page was read. `assertLakeFilter` refuses a malformed one with the new `LakeReadError` code `malformed_filter` (the message names the key, never the value); the in-memory adapter filters before the range and limit, the Convex adapter forwards it as the query argument `filter` only when non-empty, and `filter` joins the reserved argument names `tokenArg` may not take. (README: #reading-the-clients-data-lake)
|
|
14
|
+
|
|
10
15
|
## [0.8.0] - 2026-11-10
|
|
11
16
|
|
|
12
17
|
### Added
|
|
@@ -543,6 +548,7 @@ Initial published release.
|
|
|
543
548
|
- MIT license, clonable as a starter template.
|
|
544
549
|
|
|
545
550
|
[Unreleased]: https://github.com/elpiarthera/vantage-mcp-boilerplate/compare/v0.4.0...HEAD
|
|
551
|
+
[0.9.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.9.0
|
|
546
552
|
[0.8.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.8.0
|
|
547
553
|
[0.7.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.7.0
|
|
548
554
|
[0.6.3]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.3
|
package/README.fr.md
CHANGED
|
@@ -527,6 +527,14 @@ lignes que demandé, clés désordonnées ou non postérieures au curseur, page
|
|
|
527
527
|
ligne sans clé) est une `LakeReadError`, à laquelle `list_items` répond par une erreur d'outil au
|
|
528
528
|
code stable `lake_unreadable` avec la raison, jamais par une page vide ou partielle.
|
|
529
529
|
|
|
530
|
+
Une requête de page peut aussi porter un `filter` optionnel, un enregistrement plat de
|
|
531
|
+
`LakeFilterValue` (chaîne, nombre, booléen ou null pour l'égalité ; liste non vide de 100 chaînes ou
|
|
532
|
+
nombres au plus pour l'appartenance ; `{ gte?, lte? }` pour des bornes inclusives, nombres ou dates ISO d'un même type ; clés combinées par ET, 32 au plus). L'adaptateur l'applique
|
|
533
|
+
DANS la lecture par plage, comme `scope`, jamais sur des lignes déjà lues, si bien que les pages
|
|
534
|
+
restent pleines et les curseurs exacts. Un filtre mal formé est une `LakeReadError`
|
|
535
|
+
`malformed_filter` qui nomme la clé, jamais la valeur (`assertLakeFilter`) ; l'adaptateur Convex le
|
|
536
|
+
transmet comme argument de requête `filter`, seulement s'il n'est pas vide.
|
|
537
|
+
|
|
530
538
|
L'adaptateur Convex est livré sous son propre sous-chemin ; `convex` est une dépendance pair
|
|
531
539
|
optionnelle, installée seulement par un serveur qui l'importe :
|
|
532
540
|
|
package/README.md
CHANGED
|
@@ -498,6 +498,13 @@ asked, keys out of order or not after the cursor, a truncated page with no row,
|
|
|
498
498
|
is a `LakeReadError`, which `list_items` answers as a tool error with the stable code
|
|
499
499
|
`lake_unreadable` and the reason, never an empty or partial page.
|
|
500
500
|
|
|
501
|
+
A page request may also carry an optional `filter`, a flat record of `LakeFilterValue` (a string,
|
|
502
|
+
number, boolean or null for equality; a non-empty list of up to 100 strings or numbers for
|
|
503
|
+
membership; `{ gte?, lte? }` for inclusive bounds, numbers or ISO date strings of one type; keys ANDed, at most 32). The adapter applies it INSIDE the range read, like `scope`,
|
|
504
|
+
never over rows after a page was read, so pages stay full and cursors true. A malformed filter is a
|
|
505
|
+
`LakeReadError` `malformed_filter` naming the key, never the value (`assertLakeFilter`); the Convex
|
|
506
|
+
adapter forwards it as the query argument `filter`, only when non-empty.
|
|
507
|
+
|
|
501
508
|
The Convex adapter ships at its own subpath; `convex` is an optional peer dependency, installed
|
|
502
509
|
only by a server that imports it:
|
|
503
510
|
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"commit":"
|
|
1
|
+
{"commit":"5376e6a376385d26541be210e53c70126aa52282"}
|
|
@@ -216,7 +216,7 @@ export declare const EMBEDDED_LOCALES: {
|
|
|
216
216
|
};
|
|
217
217
|
};
|
|
218
218
|
};
|
|
219
|
-
export declare const EMBEDDED_PACKAGE_VERSION = "0.
|
|
219
|
+
export declare const EMBEDDED_PACKAGE_VERSION = "0.9.0";
|
|
220
220
|
export declare const EMBEDDED_PACKAGE_IDENTITY: {
|
|
221
221
|
displayName: string;
|
|
222
222
|
description: string;
|
|
@@ -4,5 +4,5 @@ export const EMBEDDED_LOCALES = {
|
|
|
4
4
|
en: { "Mcp": { "auth": { "unauthorized": "Unauthorized", "originNotAllowed": "This origin is not allowed to call this endpoint" }, "tenant": { "permissionDenied": "permission denied: no tenant resolved for this caller" }, "person": { "identityNotConfigured": "permission denied: this tool needs the verified person, and this server resolves no person (identity is not configured)", "noVerifiedSession": "permission denied: this tool needs the verified person, and this call carries no verified session", "refused": "permission denied: the verified person behind this call could not be resolved" }, "rateLimit": { "exceeded": "too many tool calls: retry in {seconds} s" }, "validation": { "messageTooLong": "message exceeds the maximum length of 200 characters" }, "oauth": { "pickerTitle": "Connect {client}", "pickerIntro": "{client} asks to act on your behalf. Choose the organisation it may reach, then approve or deny.", "handoffTitle": "Returning to the application", "handoffLink": "Continue to the application", "pickerNoAllowedOrganisation": "None of your organisations is authorised on this server.", "pickerDestination": "After approval you are sent to:", "pickerScope": "Requested access:", "pickerUnverified": "Unverified client: it registered itself automatically and nobody has vouched for it. Approve only if you recognise the address above.", "pickerOrganisation": "Organisation", "approve": "Approve", "deny": "Deny", "denied": "The request was denied; you can close this window.", "unavailable": "Authorization is not configured on this server.", "inMemoryWarning": "Running on in-memory OAuth stores: codes and registered clients are lost on restart and not shared between instances. Fine for development; set {knob}=1 to acknowledge, or inject durable adapters.", "consentTokenRequired": "The consent token is missing: reload the page and try again.", "registryFull": "Client registration is temporarily unavailable: the registry is full. Retry later.", "devInstanceRemote": "Development Clerk instance on a remote server: its hosted sign-in page redirects only to localhost or to the instance Home URL. Set the development instance's Home URL (development origin) to this server's origin for a remote test and revert it afterwards, or use a Clerk production instance (see README, \"Sign-in with ChatGPT and Claude\")." }, "manifest": { "unreadableField": "cannot read field \"{field}\" of {source} (expected: {expected})", "expect": { "string": "a non-empty string", "semver": "a semantic version (x.y.z)", "object": "an object", "boolean": "true or false", "positiveInteger": "a positive whole number", "atLeastPerTool": "at least the perTool ceiling", "stringArray": "an array of non-empty strings" } }, "toolRoles": { "identityMissing": "the identity stores are not injected (AppDeps.identity): on the OAuth tier a caller's role is decided for the organisation the identity layer resolves, so both are required", "denied": "permission denied: your role does not grant the tool {tool} ({reason})", "unresolved": "permission denied: your role could not be read, so no tool is granted", "configMissing": "{variable} is not set: on the OAuth tier every tool is decided by the caller's role, read from that roles file", "configInvalid": "{variable} ({path}) is not a valid roles file: {problem}" }, "toolRecord": { "unsound": "tool records or the context ceiling are unsound: {problems}" }, "pagination": { "invalidCursor": "invalid cursor: it was not issued by this tool or is no longer valid; start again without a cursor" }, "lake": { "unreadable": "the data source answered something this server cannot read; no row is shown rather than a partial or wrong page" }, "buildIdentity": { "unreadable": "cannot compute the build identity: {dir} is unreadable ({reason})", "empty": "cannot compute the build identity: {dir} is empty (no file), nothing was built", "commitUnreadable": "cannot read the build commit from {file} ({reason}): run npm run build, which writes it (scripts/write-build-info.mjs)" }, "config": { "refused": "configuration refused: {problems}", "missing": "{env} is missing (expected: {expected})", "invalid": "{env} is set but invalid (expected: {expected}; value not shown)", "expect": { "string": "a non-empty string", "integer": "an integer from {min} to {max}", "integerAtLeast": "an integer of at least {min}", "boolean": "\"0\" or \"1\"", "url": "an https URL (http on loopback only)", "originList": "a comma-separated list of origins (scheme://host[:port], no path)", "enum": "one of: {values}", "json": "a JSON object or array" } }, "server": { "instructions": "This server exposes three read-only example tools.\n1. Call whoami first. It returns who is calling on this connection (null on the public tier and on stdio), the caller's role, and grantedTools: the tools that role may call. Never ask the user for their identity and never pass one as an argument.\n2. ping proves the connection end to end; pass message to have it echoed back.\n3. list_items lists the items of the caller's organisation and renders them in an interactive widget where the client supports MCP Apps. It is paged: pass the returned nextCursor to read the next page; exhausted: true means the last page. Never invent a cursor.\n4. A result with isError and structuredContent.error.code is a refusal, not an empty result: rate_limited (wait retryAfterMs, then retry once), invalid_cursor (restart from the first page), lake_unreadable, TOOL_ROLE_DENIED or TOOL_ROLE_UNRESOLVED (the caller may not use this tool). Report it to the user; never read it as \"nothing exists\".\n5. Send the X-MCP-Locale header (en or fr) to receive messages in that language.", "identityMissing": "the server identity has no {item}: set {variable}, or the \"{field}\" field of package.json", "identityInvalid": "the server identity's {item} is not an https URL (http on a loopback host only): {source}" }, "landing": { "pageTitle": "{title} — MCP server", "intro": "This address is a Model Context Protocol (MCP) server. Add it to your AI client as a remote MCP server:", "claudeAi": "Claude.ai: add a custom connector with this URL, then Connect.", "chatgpt": "ChatGPT: add a custom MCP server (developer mode) with this URL.", "claudeCode": "Claude Code:", "other": "Any other MCP client: a Streamable HTTP server at the URL above.", "authOauth": "Your client then opens a sign-in page (OAuth 2.1). There is no token to copy.", "authPublic": "This server runs without sign-in: connect directly.", "website": "Website:" }, "widget": { "description": "Shows the example items returned by list_items as a list, with their name and status; read-only, it makes no request of its own." } } },
|
|
5
5
|
fr: { "Mcp": { "auth": { "unauthorized": "Non autorisé", "originNotAllowed": "Cette origine n'est pas autorisée à appeler ce point d'accès" }, "tenant": { "permissionDenied": "permission refusée : aucun tenant résolu pour cet appelant" }, "person": { "identityNotConfigured": "permission refusée : cet outil exige la personne vérifiée, et ce serveur ne résout aucune personne (identité non configurée)", "noVerifiedSession": "permission refusée : cet outil exige la personne vérifiée, et cet appel ne porte aucune session vérifiée", "refused": "permission refusée : la personne vérifiée derrière cet appel n'a pas pu être résolue" }, "rateLimit": { "exceeded": "trop d’appels d’outils : réessayez dans {seconds} s" }, "validation": { "messageTooLong": "le message dépasse la longueur maximale de 200 caractères" }, "oauth": { "pickerTitle": "Connecter {client}", "pickerIntro": "{client} demande à agir en votre nom. Choisissez l'organisation à laquelle il peut accéder, puis approuvez ou refusez.", "handoffTitle": "Retour à l'application", "handoffLink": "Continuer vers l'application", "pickerNoAllowedOrganisation": "Aucune de vos organisations n'est autorisée sur ce serveur.", "pickerDestination": "Redirection après validation vers :", "pickerScope": "Accès demandé :", "pickerUnverified": "Client non vérifié : il s'est enregistré automatiquement et personne ne s'en porte garant. N'approuvez que si vous reconnaissez l'adresse ci-dessus.", "pickerOrganisation": "Organisation", "approve": "Approuver", "deny": "Refuser", "denied": "La demande a été refusée ; vous pouvez fermer cette fenêtre.", "unavailable": "L'autorisation n'est pas configurée sur ce serveur.", "inMemoryWarning": "Stores OAuth en mémoire : les codes et les clients enregistrés sont perdus au redémarrage et non partagés entre instances. Acceptable en développement ; définissez {knob}=1 pour l'accepter, ou injectez des adaptateurs durables.", "consentTokenRequired": "Le jeton de consentement est absent : rechargez la page et réessayez.", "registryFull": "L'enregistrement de clients est temporairement indisponible : le registre est plein. Réessayez plus tard.", "devInstanceRemote": "Instance Clerk de développement sur un serveur distant : sa page de connexion hébergée ne redirige que vers localhost ou vers l'URL d'accueil (Home URL) de l'instance. Pour un test distant, réglez l'URL d'accueil (origine de développement) de l'instance sur l'origine de ce serveur, puis rétablissez-la ; sinon utilisez une instance Clerk de production (voir README, « Connexion avec ChatGPT et Claude »)." }, "manifest": { "unreadableField": "champ \"{field}\" illisible dans {source} (attendu : {expected})", "expect": { "string": "une chaîne non vide", "semver": "une version sémantique (x.y.z)", "object": "un objet", "boolean": "true ou false", "positiveInteger": "un entier strictement positif", "atLeastPerTool": "au moins le plafond perTool", "stringArray": "un tableau de chaînes non vides" } }, "toolRoles": { "identityMissing": "les stores d'identité ne sont pas injectés (AppDeps.identity) : sur le niveau OAuth, le rôle d'un appelant est décidé pour l'organisation que la couche d'identité résout, les deux sont donc requis", "denied": "permission refusée : votre rôle n'accorde pas l'outil {tool} ({reason})", "unresolved": "permission refusée : votre rôle n'a pas pu être lu, aucun outil n'est accordé", "configMissing": "{variable} n'est pas défini : sur le niveau OAuth, chaque outil est décidé par le rôle de l'appelant, lu dans ce fichier de rôles", "configInvalid": "{variable} ({path}) n'est pas un fichier de rôles valide : {problem}" }, "toolRecord": { "unsound": "fiches d'outils ou plafond de contexte invalides : {problems}" }, "pagination": { "invalidCursor": "curseur invalide : il n'a pas été émis par cet outil ou n'est plus valide ; recommencez sans curseur" }, "lake": { "unreadable": "la source de données a renvoyé une réponse illisible pour ce serveur ; aucune ligne n'est affichée plutôt qu'une page partielle ou fausse" }, "buildIdentity": { "unreadable": "identité de build incalculable : {dir} est illisible ({reason})", "empty": "identité de build incalculable : {dir} est vide (aucun fichier), rien n'a été construit", "commitUnreadable": "impossible de lire le commit de build dans {file} ({reason}) : lancez npm run build, qui l'écrit (scripts/write-build-info.mjs)" }, "config": { "refused": "configuration refusée : {problems}", "missing": "{env} est manquante (attendu : {expected})", "invalid": "{env} est définie mais invalide (attendu : {expected} ; valeur non affichée)", "expect": { "string": "une chaîne non vide", "integer": "un entier de {min} à {max}", "integerAtLeast": "un entier d'au moins {min}", "boolean": "\"0\" ou \"1\"", "url": "une URL https (http seulement sur la boucle locale)", "originList": "une liste d'origines séparées par des virgules (schéma://hôte[:port], sans chemin)", "enum": "une valeur parmi : {values}", "json": "un objet ou un tableau JSON" } }, "server": { "instructions": "Ce serveur expose trois outils d'exemple en lecture seule.\n1. Appelez whoami d'abord. Il renvoie qui appelle sur cette connexion (null sur le niveau public et en stdio), le rôle de l'appelant et grantedTools : les outils que ce rôle peut appeler. Ne demandez jamais son identité à l'utilisateur et n'en passez jamais une en argument.\n2. ping prouve la connexion de bout en bout ; passez message pour qu'il soit renvoyé tel quel.\n3. list_items liste les éléments de l'organisation de l'appelant et les affiche dans un widget interactif quand le client prend en charge MCP Apps. Il est paginé : passez le nextCursor renvoyé pour lire la page suivante ; exhausted: true signale la dernière page. N'inventez jamais de curseur.\n4. Un résultat avec isError et structuredContent.error.code est un refus, pas un résultat vide : rate_limited (attendez retryAfterMs, puis réessayez une fois), invalid_cursor (repartez de la première page), lake_unreadable, TOOL_ROLE_DENIED ou TOOL_ROLE_UNRESOLVED (l'appelant ne peut pas utiliser cet outil). Signalez-le à l'utilisateur ; ne le lisez jamais comme « rien n'existe ».\n5. Envoyez l'en-tête X-MCP-Locale (en ou fr) pour recevoir les messages dans cette langue.", "identityMissing": "l'identité du serveur n'a pas de {item} : définissez {variable}, ou le champ « {field} » de package.json", "identityInvalid": "{item} de l'identité du serveur n'est pas une URL https (http sur un hôte de bouclage seulement) : {source}" }, "landing": { "pageTitle": "{title} — serveur MCP", "intro": "Cette adresse est un serveur Model Context Protocol (MCP). Ajoutez-la à votre client IA comme serveur MCP distant :", "claudeAi": "Claude.ai : ajoutez un connecteur personnalisé avec cette URL, puis Connecter.", "chatgpt": "ChatGPT : ajoutez un serveur MCP personnalisé (mode développeur) avec cette URL.", "claudeCode": "Claude Code :", "other": "Tout autre client MCP : un serveur Streamable HTTP à l'URL ci-dessus.", "authOauth": "Votre client ouvre ensuite une page de connexion (OAuth 2.1). Aucun jeton à copier.", "authPublic": "Ce serveur fonctionne sans connexion : branchez-vous directement.", "website": "Site :" }, "widget": { "description": "Affiche sous forme de liste les éléments d'exemple renvoyés par list_items, avec leur nom et leur statut ; en lecture seule, il ne fait aucune requête lui-même." } } },
|
|
6
6
|
};
|
|
7
|
-
export const EMBEDDED_PACKAGE_VERSION = "0.
|
|
7
|
+
export const EMBEDDED_PACKAGE_VERSION = "0.9.0";
|
|
8
8
|
export const EMBEDDED_PACKAGE_IDENTITY = { "displayName": "MCP Boilerplate", "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.", "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.", "homepage": "https://github.com/elpiarthera/vantage-mcp-boilerplate#readme" };
|
package/dist/lake/convex.d.ts
CHANGED
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
* rows whose key field is strictly after `after`, ascending by that key. The adapter asks for
|
|
30
30
|
* ONE row more than the page: its presence tells a truncated page from an exhausted one.
|
|
31
31
|
* `tenantId` is present only on a scoped read; the function applies it in its index range.
|
|
32
|
+
* `filter` (a flat record, see `LakeFilterValue`: a scalar means equality, a list means
|
|
33
|
+
* membership, `{ gte?, lte? }` means inclusive bounds, keys ANDed) is present only when the request carries a non-empty one; the function
|
|
34
|
+
* applies it in the same range read, never over the rows it returns (a store-side filter keeps
|
|
35
|
+
* pages full and cursors true). Absent, the arguments are exactly those without it.
|
|
32
36
|
* - get `{ key: string, tenantId?: string }` -> the row with that key, or null.
|
|
33
37
|
* With `tokenArg`, every call also carries the read token under that argument name, for a
|
|
34
38
|
* function that checks a shared read key. Without it, the token goes through `setAuth`
|
package/dist/lake/convex.js
CHANGED
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
* rows whose key field is strictly after `after`, ascending by that key. The adapter asks for
|
|
30
30
|
* ONE row more than the page: its presence tells a truncated page from an exhausted one.
|
|
31
31
|
* `tenantId` is present only on a scoped read; the function applies it in its index range.
|
|
32
|
+
* `filter` (a flat record, see `LakeFilterValue`: a scalar means equality, a list means
|
|
33
|
+
* membership, `{ gte?, lte? }` means inclusive bounds, keys ANDed) is present only when the request carries a non-empty one; the function
|
|
34
|
+
* applies it in the same range read, never over the rows it returns (a store-side filter keeps
|
|
35
|
+
* pages full and cursors true). Absent, the arguments are exactly those without it.
|
|
32
36
|
* - get `{ key: string, tenantId?: string }` -> the row with that key, or null.
|
|
33
37
|
* With `tokenArg`, every call also carries the read token under that argument name, for a
|
|
34
38
|
* function that checks a shared read key. Without it, the token goes through `setAuth`
|
|
@@ -50,7 +54,7 @@ import { ConvexHttpClient } from "convex/browser";
|
|
|
50
54
|
import { makeFunctionReference } from "convex/server";
|
|
51
55
|
import { defineVariables, isSecureUrl, loadVariables } from "../declaredVariables.js";
|
|
52
56
|
import { boundedFetch, configuredUpstreamTimeoutMs } from "../upstream/timeout.js";
|
|
53
|
-
import { LakeReadError, assertOrderedKeys } from "./reader.js";
|
|
57
|
+
import { LakeReadError, assertLakeFilter, assertOrderedKeys, hasLakeFilter, } from "./reader.js";
|
|
54
58
|
const ARGUMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
55
59
|
function configurationError(message) {
|
|
56
60
|
return new Error(`convex lake reader: ${message}`);
|
|
@@ -84,7 +88,7 @@ function checkOptions(options) {
|
|
|
84
88
|
}
|
|
85
89
|
if (options.token === undefined)
|
|
86
90
|
throw configurationError("tokenArg is named but no token");
|
|
87
|
-
if (["after", "limit", "tenantId", "key"].includes(options.tokenArg)) {
|
|
91
|
+
if (["after", "limit", "tenantId", "key", "filter"].includes(options.tokenArg)) {
|
|
88
92
|
throw configurationError("tokenArg collides with an argument the adapter sends");
|
|
89
93
|
}
|
|
90
94
|
}
|
|
@@ -169,12 +173,15 @@ export function createConvexLakeReader(options) {
|
|
|
169
173
|
}
|
|
170
174
|
return key;
|
|
171
175
|
},
|
|
172
|
-
async listPage({ after, limit, scope }) {
|
|
176
|
+
async listPage({ after, limit, scope, filter }) {
|
|
177
|
+
if (filter !== undefined)
|
|
178
|
+
assertLakeFilter(filter);
|
|
173
179
|
const asked = limit + 1;
|
|
174
180
|
const raw = await call("page", {
|
|
175
181
|
...(after === undefined ? {} : { after }),
|
|
176
182
|
limit: asked,
|
|
177
183
|
...scoped(scope),
|
|
184
|
+
...(hasLakeFilter(filter) ? { filter } : {}),
|
|
178
185
|
});
|
|
179
186
|
if (!Array.isArray(raw)) {
|
|
180
187
|
throw new LakeReadError("malformed_page", `${functions.page} did not return a list`);
|
package/dist/lake/reader.d.ts
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
* - `scope` (the caller's resolved tenant) is applied INSIDE the range read — a compound
|
|
14
14
|
* (tenant, key) index — never as a filter after the limit, which would shorten a page and make
|
|
15
15
|
* its cursor lie;
|
|
16
|
+
* - `filter` (optional, a flat record of `LakeFilterValue`) is applied by the adapter INSIDE the
|
|
17
|
+
* range read, exactly like `scope`: a scalar matches by equality, a list by membership (an IN
|
|
18
|
+
* list), a `{ gte?, lte? }` object by inclusive bounds (number or string, same type as the
|
|
19
|
+
* row value, a row of the other type never matches), several keys are ANDed, a key absent from a row never matches. It is never applied to
|
|
20
|
+
* rows after a page was read (that shortens pages and makes cursors lie). `checkLakePage`
|
|
21
|
+
* cannot verify that a store honoured it: a declared limit, held by each adapter's own tests;
|
|
16
22
|
* - `get(key, scope)` returns the row with that key, or undefined.
|
|
17
23
|
*
|
|
18
24
|
* What a derived server adds: its own filters (a status, a date window, a person's portfolio) as
|
|
@@ -33,12 +39,28 @@ import { type TenantOwned } from "../tenancy.js";
|
|
|
33
39
|
export interface LakeScope {
|
|
34
40
|
readonly tenantId: string;
|
|
35
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Inclusive bounds on one field: at least one of `gte` / `lte`, both of the same primitive type
|
|
44
|
+
* when both are present. Numbers compare as numbers (the row value must be a finite number),
|
|
45
|
+
* strings lexicographically (ISO dates compare correctly; the row value must be a string).
|
|
46
|
+
*/
|
|
47
|
+
export interface LakeRange {
|
|
48
|
+
readonly gte?: string | number;
|
|
49
|
+
readonly lte?: string | number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One filter value, three forms: a scalar (equality), a non-empty list of strings/numbers
|
|
53
|
+
* (membership), or a `LakeRange` object (bounds).
|
|
54
|
+
*/
|
|
55
|
+
export type LakeFilterValue = string | number | boolean | null | readonly (string | number)[] | LakeRange;
|
|
36
56
|
export interface LakePageRequest {
|
|
37
57
|
/** The last key already served; omitted for the first page. */
|
|
38
58
|
readonly after?: string;
|
|
39
59
|
/** At most this many rows. A positive integer, already bounded by the tool. */
|
|
40
60
|
readonly limit: number;
|
|
41
61
|
readonly scope?: LakeScope;
|
|
62
|
+
/** Applied inside the range read by the adapter, like `scope`. See `assertLakeFilter`. */
|
|
63
|
+
readonly filter?: Readonly<Record<string, LakeFilterValue>>;
|
|
42
64
|
}
|
|
43
65
|
export interface LakePage<Row> {
|
|
44
66
|
readonly rows: readonly Row[];
|
|
@@ -57,17 +79,28 @@ export interface LakeReader<Row> {
|
|
|
57
79
|
* - `malformed_page`: the page breaks the contract (not a list, more rows than asked, keys out of
|
|
58
80
|
* order or not after the cursor, a truncated page with no row);
|
|
59
81
|
* - `malformed_row`: a row has no readable key, or the derived server's row parser refused it;
|
|
82
|
+
* - `malformed_filter`: the request's filter is not a flat record of allowed values (the message
|
|
83
|
+
* names the offending key, never its value);
|
|
60
84
|
* - `out_of_scope`: a row of another tenant came back from a scoped read;
|
|
61
85
|
* - `read_failed`: the store call itself failed (its message is not forwarded: it may carry a
|
|
62
86
|
* URL or a credential).
|
|
63
87
|
*/
|
|
64
|
-
export type LakeReadErrorCode = "malformed_page" | "malformed_row" | "out_of_scope" | "read_failed";
|
|
88
|
+
export type LakeReadErrorCode = "malformed_page" | "malformed_row" | "malformed_filter" | "out_of_scope" | "read_failed";
|
|
65
89
|
/** The stable machine code of a tool result refused because the lake answer was unreadable. */
|
|
66
90
|
export declare const LAKE_UNREADABLE_CODE = "lake_unreadable";
|
|
67
91
|
export declare class LakeReadError extends Error {
|
|
68
92
|
readonly code: LakeReadErrorCode;
|
|
69
93
|
constructor(code: LakeReadErrorCode, message: string);
|
|
70
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Refuses a filter that is not a flat record: keys `[A-Za-z_][A-Za-z0-9_]*`, at most 32; values a
|
|
97
|
+
* string, a finite number, a boolean, null, or a non-empty list (at most 100) of strings/numbers;
|
|
98
|
+
* no nested object, no undefined. A refusal is `malformed_filter`; its message names the key,
|
|
99
|
+
* never the value. Both adapters call it before reading.
|
|
100
|
+
*/
|
|
101
|
+
export declare function assertLakeFilter(filter: unknown): asserts filter is NonNullable<LakePageRequest["filter"]>;
|
|
102
|
+
/** True when `request.filter` carries at least one key (an empty filter is no filter). */
|
|
103
|
+
export declare function hasLakeFilter(filter: LakePageRequest["filter"]): filter is NonNullable<LakePageRequest["filter"]>;
|
|
71
104
|
/**
|
|
72
105
|
* Checks that `keys` are strictly ascending and all strictly after `after`. Shared by
|
|
73
106
|
* `checkLakePage` and by adapters that read a raw answer.
|
|
@@ -78,7 +111,7 @@ export declare function checkLakePage<Row>(reader: Pick<LakeReader<Row>, "keyOf"
|
|
|
78
111
|
/**
|
|
79
112
|
* An in-memory reader over rows SORTED strictly ascending by `id` (refused otherwise, at
|
|
80
113
|
* creation: the range read relies on the order). The scope keeps the rows stamped with the
|
|
81
|
-
* caller's organisation (tenancy.ts `scopeRowsForTenant`) BEFORE the range, so a page is never
|
|
114
|
+
* caller's organisation (tenancy.ts `scopeRowsForTenant`) and the `filter` keep the matching rows BEFORE the range, so a page is never
|
|
82
115
|
* shortened afterwards. A store-backed reader reads the same range from a (tenant, key) index.
|
|
83
116
|
*/
|
|
84
117
|
export declare function createInMemoryLakeReader<Row extends {
|
package/dist/lake/reader.js
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
* - `scope` (the caller's resolved tenant) is applied INSIDE the range read — a compound
|
|
14
14
|
* (tenant, key) index — never as a filter after the limit, which would shorten a page and make
|
|
15
15
|
* its cursor lie;
|
|
16
|
+
* - `filter` (optional, a flat record of `LakeFilterValue`) is applied by the adapter INSIDE the
|
|
17
|
+
* range read, exactly like `scope`: a scalar matches by equality, a list by membership (an IN
|
|
18
|
+
* list), a `{ gte?, lte? }` object by inclusive bounds (number or string, same type as the
|
|
19
|
+
* row value, a row of the other type never matches), several keys are ANDed, a key absent from a row never matches. It is never applied to
|
|
20
|
+
* rows after a page was read (that shortens pages and makes cursors lie). `checkLakePage`
|
|
21
|
+
* cannot verify that a store honoured it: a declared limit, held by each adapter's own tests;
|
|
16
22
|
* - `get(key, scope)` returns the row with that key, or undefined.
|
|
17
23
|
*
|
|
18
24
|
* What a derived server adds: its own filters (a status, a date window, a person's portfolio) as
|
|
@@ -39,6 +45,93 @@ export class LakeReadError extends Error {
|
|
|
39
45
|
this.code = code;
|
|
40
46
|
}
|
|
41
47
|
}
|
|
48
|
+
const FILTER_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
49
|
+
const FILTER_MAX_KEYS = 32;
|
|
50
|
+
const FILTER_MAX_LIST = 100;
|
|
51
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
|
+
const isBound = (value) => typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
|
|
53
|
+
/** A well-formed `LakeRange`: only gte/lte, at least one, same type, gte <= lte. */
|
|
54
|
+
function isLakeRange(candidate) {
|
|
55
|
+
const value = candidate;
|
|
56
|
+
const keys = Object.keys(value);
|
|
57
|
+
if (keys.length === 0 || !keys.every((k) => k === "gte" || k === "lte"))
|
|
58
|
+
return false;
|
|
59
|
+
const { gte, lte } = value;
|
|
60
|
+
if (keys.includes("gte") && !isBound(gte))
|
|
61
|
+
return false;
|
|
62
|
+
if (keys.includes("lte") && !isBound(lte))
|
|
63
|
+
return false;
|
|
64
|
+
if (gte !== undefined && lte !== undefined) {
|
|
65
|
+
if (typeof gte !== typeof lte)
|
|
66
|
+
return false;
|
|
67
|
+
if (gte > lte)
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
/** True when the row value satisfies the bounds (same type as the bound, else never). */
|
|
73
|
+
function inRange(actual, range) {
|
|
74
|
+
for (const [bound, ok] of [
|
|
75
|
+
[range.gte, (a, b) => a >= b],
|
|
76
|
+
[range.lte, (a, b) => a <= b],
|
|
77
|
+
]) {
|
|
78
|
+
if (bound === undefined)
|
|
79
|
+
continue;
|
|
80
|
+
if (typeof bound === "number"
|
|
81
|
+
? typeof actual !== "number" || !Number.isFinite(actual)
|
|
82
|
+
: typeof actual !== "string") {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (!ok(actual, bound))
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const isFilterScalar = (value) => value === null ||
|
|
91
|
+
typeof value === "boolean" ||
|
|
92
|
+
typeof value === "string" ||
|
|
93
|
+
(typeof value === "number" && Number.isFinite(value));
|
|
94
|
+
/**
|
|
95
|
+
* Refuses a filter that is not a flat record: keys `[A-Za-z_][A-Za-z0-9_]*`, at most 32; values a
|
|
96
|
+
* string, a finite number, a boolean, null, or a non-empty list (at most 100) of strings/numbers;
|
|
97
|
+
* no nested object, no undefined. A refusal is `malformed_filter`; its message names the key,
|
|
98
|
+
* never the value. Both adapters call it before reading.
|
|
99
|
+
*/
|
|
100
|
+
export function assertLakeFilter(filter) {
|
|
101
|
+
if (typeof filter !== "object" || filter === null || Array.isArray(filter)) {
|
|
102
|
+
throw new LakeReadError("malformed_filter", "the filter is not a record");
|
|
103
|
+
}
|
|
104
|
+
const keys = Object.keys(filter);
|
|
105
|
+
if (keys.length > FILTER_MAX_KEYS) {
|
|
106
|
+
throw new LakeReadError("malformed_filter", `the filter holds more than ${FILTER_MAX_KEYS} keys`);
|
|
107
|
+
}
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
if (!FILTER_KEY.test(key)) {
|
|
110
|
+
throw new LakeReadError("malformed_filter", `a filter key is not a plain name (${JSON.stringify(key)})`);
|
|
111
|
+
}
|
|
112
|
+
const value = filter[key];
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
const listOk = value.length > 0 &&
|
|
115
|
+
value.length <= FILTER_MAX_LIST &&
|
|
116
|
+
value.every((v) => typeof v === "string" || (typeof v === "number" && Number.isFinite(v)));
|
|
117
|
+
if (!listOk) {
|
|
118
|
+
throw new LakeReadError("malformed_filter", `filter "${key}" must be a list of 1 to ${FILTER_MAX_LIST} strings or numbers`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (isPlainObject(value)) {
|
|
122
|
+
if (!isLakeRange(value)) {
|
|
123
|
+
throw new LakeReadError("malformed_filter", `filter "${key}" must be bounds { gte?, lte? }: at least one, the same type, gte not above lte`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else if (!isFilterScalar(value)) {
|
|
127
|
+
throw new LakeReadError("malformed_filter", `filter "${key}" must be a string, number, boolean, null, a list of strings or numbers, or bounds { gte?, lte? }`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** True when `request.filter` carries at least one key (an empty filter is no filter). */
|
|
132
|
+
export function hasLakeFilter(filter) {
|
|
133
|
+
return filter !== undefined && Object.keys(filter).length > 0;
|
|
134
|
+
}
|
|
42
135
|
/** The row's key, or a `malformed_row` refusal when it is not a non-empty string. */
|
|
43
136
|
function readKey(keyOf, row) {
|
|
44
137
|
let key;
|
|
@@ -92,7 +185,7 @@ export function checkLakePage(reader, request, page) {
|
|
|
92
185
|
/**
|
|
93
186
|
* An in-memory reader over rows SORTED strictly ascending by `id` (refused otherwise, at
|
|
94
187
|
* creation: the range read relies on the order). The scope keeps the rows stamped with the
|
|
95
|
-
* caller's organisation (tenancy.ts `scopeRowsForTenant`) BEFORE the range, so a page is never
|
|
188
|
+
* caller's organisation (tenancy.ts `scopeRowsForTenant`) and the `filter` keep the matching rows BEFORE the range, so a page is never
|
|
96
189
|
* shortened afterwards. A store-backed reader reads the same range from a (tenant, key) index.
|
|
97
190
|
*/
|
|
98
191
|
export function createInMemoryLakeReader(sortedRows) {
|
|
@@ -104,11 +197,29 @@ export function createInMemoryLakeReader(sortedRows) {
|
|
|
104
197
|
}
|
|
105
198
|
}
|
|
106
199
|
const scoped = (scope) => scope ? scopeRowsForTenant(scope.tenantId, sortedRows) : sortedRows;
|
|
200
|
+
// The filter is a predicate over the scoped rows, applied BEFORE the range and the limit.
|
|
201
|
+
const matching = (rows, filter) => {
|
|
202
|
+
if (!hasLakeFilter(filter))
|
|
203
|
+
return rows;
|
|
204
|
+
const entries = Object.entries(filter);
|
|
205
|
+
return rows.filter((row) => entries.every(([key, wanted]) => {
|
|
206
|
+
if (!Object.hasOwn(row, key))
|
|
207
|
+
return false;
|
|
208
|
+
const actual = row[key];
|
|
209
|
+
if (Array.isArray(wanted))
|
|
210
|
+
return wanted.includes(actual);
|
|
211
|
+
if (isPlainObject(wanted))
|
|
212
|
+
return inRange(actual, wanted);
|
|
213
|
+
return actual === wanted;
|
|
214
|
+
}));
|
|
215
|
+
};
|
|
107
216
|
return Object.freeze({
|
|
108
217
|
keyOf: (row) => row.id,
|
|
109
218
|
async listPage(request) {
|
|
219
|
+
if (request.filter !== undefined)
|
|
220
|
+
assertLakeFilter(request.filter);
|
|
110
221
|
// One row more than the page: its existence separates truncated from exhausted.
|
|
111
|
-
const window = rangeAfter(scoped(request.scope), request.after, request.limit + 1);
|
|
222
|
+
const window = rangeAfter(matching(scoped(request.scope), request.filter), request.after, request.limit + 1);
|
|
112
223
|
return { rows: window.slice(0, request.limit), exhausted: window.length <= request.limit };
|
|
113
224
|
},
|
|
114
225
|
async get(key, scope) {
|
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.9.0",
|
|
4
4
|
"displayName": "MCP Boilerplate",
|
|
5
5
|
"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.",
|
|
6
6
|
"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.",
|
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.9.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"
|