@vantageos/mcp-boilerplate 0.2.4 → 0.3.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 +147 -0
- package/README.fr.md +318 -15
- package/README.md +302 -15
- package/dist/buildIdentity.d.ts +19 -0
- package/dist/buildIdentity.js +62 -0
- package/dist/declaredVariables.d.ts +205 -0
- package/dist/declaredVariables.js +451 -0
- package/dist/exampleItems.d.ts +15 -0
- package/dist/exampleItems.js +28 -1
- package/dist/httpApp.d.ts +17 -0
- package/dist/httpApp.js +19 -2
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/lake/convex.d.ts +101 -0
- package/dist/lake/convex.js +224 -0
- package/dist/lake/reader.d.ts +88 -0
- package/dist/lake/reader.js +120 -0
- package/dist/locales/en.json +42 -0
- package/dist/locales/fr.json +42 -0
- package/dist/mcpServerFactory.d.ts +31 -14
- package/dist/mcpServerFactory.js +116 -48
- package/dist/oauth/authorizationServer.js +63 -18
- package/dist/oauth/clerk.d.ts +2 -0
- package/dist/oauth/clerk.js +15 -5
- package/dist/oauth/clientRegistry.d.ts +19 -4
- package/dist/oauth/clientRegistry.js +20 -10
- package/dist/oauth/picker.d.ts +5 -2
- package/dist/oauth/picker.js +34 -4
- package/dist/oauth/registerLimiter.d.ts +34 -0
- package/dist/oauth/registerLimiter.js +95 -0
- package/dist/oauth/settings.d.ts +2 -0
- package/dist/oauth/settings.js +2 -0
- package/dist/pagination.d.ts +66 -0
- package/dist/pagination.js +100 -0
- package/dist/serverManifest.d.ts +32 -0
- package/dist/serverManifest.js +68 -0
- package/dist/stdio.js +5 -1
- package/dist/tenancy.d.ts +1 -1
- package/dist/toolRecord.d.ts +94 -0
- package/dist/toolRecord.js +52 -0
- package/dist/ui/uiResource.d.ts +11 -4
- package/dist/ui/uiResource.js +8 -4
- package/dist/upstream/clientCredentials.d.ts +106 -0
- package/dist/upstream/clientCredentials.js +283 -0
- package/dist/upstream/paginatedClient.d.ts +142 -0
- package/dist/upstream/paginatedClient.js +422 -0
- package/dist/verifiedPerson.d.ts +85 -0
- package/dist/verifiedPerson.js +112 -0
- package/docs/oauth-stores.md +2 -1
- package/examples/oauth.env.example +5 -0
- package/mcp-server.json +13 -0
- package/package.json +19 -4
- package/plugin.json +1 -1
- package/skills/connect-mcp-server/SKILL.md +5 -5
package/dist/exampleItems.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Ascending order of the collection key, the order `rangeAfter` binary-searches. */
|
|
2
|
+
const byKey = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
1
3
|
export const EXAMPLE_ITEMS = [
|
|
2
4
|
{
|
|
3
5
|
id: "item-1",
|
|
@@ -20,4 +22,29 @@ export const EXAMPLE_ITEMS = [
|
|
|
20
22
|
createdBy: "org_b",
|
|
21
23
|
namespace: "org_b",
|
|
22
24
|
},
|
|
23
|
-
];
|
|
25
|
+
].sort(byKey);
|
|
26
|
+
/**
|
|
27
|
+
* The rows strictly after `afterKey` (all rows when it is undefined), at most `limit` of them,
|
|
28
|
+
* from a collection SORTED by `id`: a binary search for the first key above `afterKey`, then a
|
|
29
|
+
* slice of the page. That is the `indexed-range` mechanism the list tools declare
|
|
30
|
+
* (src/toolRecord.ts): the cost follows the page, not the collection. It requires sorted input;
|
|
31
|
+
* `EXAMPLE_ITEMS` is sorted above and scoping by tenant (a filter) preserves the order.
|
|
32
|
+
*
|
|
33
|
+
* In this in-memory seed the tenant scoping that precedes it is a filter over the rows. A fork
|
|
34
|
+
* backed by a store reads the same range from a compound (orgId, id) index instead.
|
|
35
|
+
*/
|
|
36
|
+
export function rangeAfter(sortedRows, afterKey, limit) {
|
|
37
|
+
let low = 0;
|
|
38
|
+
if (afterKey !== undefined) {
|
|
39
|
+
let high = sortedRows.length;
|
|
40
|
+
while (low < high) {
|
|
41
|
+
const mid = (low + high) >>> 1;
|
|
42
|
+
const key = sortedRows[mid]?.id ?? "";
|
|
43
|
+
if (key <= afterKey)
|
|
44
|
+
low = mid + 1;
|
|
45
|
+
else
|
|
46
|
+
high = mid;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return sortedRows.slice(low, limit === undefined ? undefined : low + limit);
|
|
50
|
+
}
|
package/dist/httpApp.d.ts
CHANGED
|
@@ -25,8 +25,11 @@
|
|
|
25
25
|
import { Hono } from "hono";
|
|
26
26
|
import { type AuthConfig } from "./auth.js";
|
|
27
27
|
import type { AuthMode } from "./config.js";
|
|
28
|
+
import type { ExampleItem } from "./exampleItems.js";
|
|
29
|
+
import type { LakeReader } from "./lake/reader.js";
|
|
28
30
|
import { type AuthorizationServerDeps } from "./oauth/authorizationServer.js";
|
|
29
31
|
import { type ResolvedTenant } from "./tenancy.js";
|
|
32
|
+
import { type CallerResolution, type IdentityDeps } from "./verifiedPerson.js";
|
|
30
33
|
export interface AppDeps {
|
|
31
34
|
authMode?: AuthMode;
|
|
32
35
|
/** External-issuer resource-server configuration (no authorization server mounted). */
|
|
@@ -35,9 +38,23 @@ export interface AppDeps {
|
|
|
35
38
|
authorizationServer?: AuthorizationServerDeps;
|
|
36
39
|
/** Dedicated sandbox origin for the MCP Apps widget (`_meta.ui.domain`). */
|
|
37
40
|
uiDomain?: string;
|
|
41
|
+
/**
|
|
42
|
+
* What @vantageos/cloud-identity needs to resolve the caller's verified PERSON
|
|
43
|
+
* (src/verifiedPerson.ts): the organisation mapping lookups, the principal lookups and the
|
|
44
|
+
* admin roles (required, non-empty, no default). Injected by the deployment, never built here.
|
|
45
|
+
* Absent: no person is resolved, and a tool that requires one is refused
|
|
46
|
+
* (`identity_not_configured`).
|
|
47
|
+
*/
|
|
48
|
+
identity?: IdentityDeps;
|
|
49
|
+
/**
|
|
50
|
+
* The data-lake reader `list_items` reads through (src/lake/reader.ts), read-only by design.
|
|
51
|
+
* Injected by the deployment like `identity`; absent, the in-memory seed is read.
|
|
52
|
+
*/
|
|
53
|
+
lake?: LakeReader<ExampleItem>;
|
|
38
54
|
}
|
|
39
55
|
type Variables = {
|
|
40
56
|
tenant?: ResolvedTenant;
|
|
57
|
+
caller?: CallerResolution;
|
|
41
58
|
};
|
|
42
59
|
export declare function buildHttpApp(deps?: AppDeps): Hono<{
|
|
43
60
|
Variables: Variables;
|
package/dist/httpApp.js
CHANGED
|
@@ -26,11 +26,14 @@ import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
|
26
26
|
import { unauthorizedChallenge } from "@vantageos/cloud-identity";
|
|
27
27
|
import { Hono } from "hono";
|
|
28
28
|
import { authenticateRequest, protectedResourceMetadataDocument } from "./auth.js";
|
|
29
|
+
import { getBuildIdentity } from "./buildIdentity.js";
|
|
29
30
|
import { detectLocale, t } from "./i18n.js";
|
|
30
31
|
import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
|
|
31
32
|
import { createAuthorizationServer, } from "./oauth/authorizationServer.js";
|
|
32
33
|
import { MCP_SCOPE } from "./oauth/settings.js";
|
|
33
34
|
import { resolveTenantFromJwtPayload } from "./tenancy.js";
|
|
35
|
+
import { assertIdentityDeps, resolveVerifiedPerson, } from "./verifiedPerson.js";
|
|
36
|
+
import { SERVER_VERSION } from "./version.js";
|
|
34
37
|
const PRM_PATHS = [
|
|
35
38
|
"/.well-known/oauth-protected-resource",
|
|
36
39
|
"/.well-known/oauth-protected-resource/mcp",
|
|
@@ -38,7 +41,11 @@ const PRM_PATHS = [
|
|
|
38
41
|
export function buildHttpApp(deps = {}) {
|
|
39
42
|
const app = new Hono();
|
|
40
43
|
const authMode = deps.authMode ?? "public";
|
|
41
|
-
|
|
44
|
+
if (deps.identity)
|
|
45
|
+
assertIdentityDeps(deps.identity);
|
|
46
|
+
// Unauthenticated (S-7): the version AND the identity of the tree being served (C-25), so a
|
|
47
|
+
// deployment is proven from what the server returns, not from what the deploy printed.
|
|
48
|
+
app.get("/health", (c) => c.json({ status: "ok", version: SERVER_VERSION, buildId: getBuildIdentity() }));
|
|
42
49
|
if (authMode === "oauth") {
|
|
43
50
|
// Resolves the resource-server configuration per request: the own-AS tier reads its
|
|
44
51
|
// environment lazily, so a missing value is a 503 on the request, not a crash at boot.
|
|
@@ -110,18 +117,28 @@ export function buildHttpApp(deps = {}) {
|
|
|
110
117
|
catch {
|
|
111
118
|
// No organization on this token — tenant stays unset.
|
|
112
119
|
}
|
|
120
|
+
// The verified person, once per request, from the SAME verified claims, by the package
|
|
121
|
+
// resolver (verifiedPerson.ts). Not request-fatal either: an unresolved person is stored
|
|
122
|
+
// as its refusal, and only a tool that requires the person is refused, at the tool call.
|
|
123
|
+
c.set("caller", await resolveVerifiedPerson(result.session.claims, deps.identity));
|
|
113
124
|
await next();
|
|
114
125
|
});
|
|
115
126
|
}
|
|
116
127
|
// The one real MCP endpoint, both protocol eras (see header comment).
|
|
117
|
-
app.all("/mcp", (c) => {
|
|
128
|
+
app.all("/mcp", async (c) => {
|
|
118
129
|
const tenant = authMode === "oauth" ? c.get("tenant") : undefined;
|
|
130
|
+
// Public tier: no verified session exists, so the person is never resolved there.
|
|
131
|
+
const caller = authMode === "oauth"
|
|
132
|
+
? c.get("caller")
|
|
133
|
+
: await resolveVerifiedPerson(undefined, deps.identity);
|
|
119
134
|
const locale = detectLocale(c.req.header("X-MCP-Locale"));
|
|
120
135
|
const handler = createMcpHandler(() => createBoilerplateMcpServer({
|
|
121
136
|
tenant,
|
|
122
137
|
requireTenant: authMode === "oauth",
|
|
138
|
+
caller,
|
|
123
139
|
locale,
|
|
124
140
|
uiDomain: deps.uiDomain,
|
|
141
|
+
lake: deps.lake,
|
|
125
142
|
}));
|
|
126
143
|
return handler.fetch(c.req.raw);
|
|
127
144
|
});
|
package/dist/i18n.d.ts
CHANGED
|
@@ -37,3 +37,13 @@ export declare function detectLocaleFromAcceptLanguage(headerValue: string | nul
|
|
|
37
37
|
* locale is somehow missing a key the English resource carries.
|
|
38
38
|
*/
|
|
39
39
|
export declare function t(locale: McpLocale, key: string): string;
|
|
40
|
+
/** A `tBoth` placeholder value: literal text, or the key of a message translated per language. */
|
|
41
|
+
export type BothParam = string | {
|
|
42
|
+
key: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A message in BOTH languages in one string ("EN / FR"), with `{name}` placeholders filled. A
|
|
46
|
+
* placeholder given as `{ key }` is itself translated, in the language being rendered. For
|
|
47
|
+
* failures raised where no caller locale exists: process start, build and release scripts.
|
|
48
|
+
*/
|
|
49
|
+
export declare function tBoth(key: string, params?: Record<string, BothParam>): string;
|
package/dist/i18n.js
CHANGED
|
@@ -67,6 +67,18 @@ export function t(locale, key) {
|
|
|
67
67
|
const fromEnglish = readPath(RESOURCES.en.Mcp, path);
|
|
68
68
|
return typeof fromEnglish === "string" ? fromEnglish : key;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* A message in BOTH languages in one string ("EN / FR"), with `{name}` placeholders filled. A
|
|
72
|
+
* placeholder given as `{ key }` is itself translated, in the language being rendered. For
|
|
73
|
+
* failures raised where no caller locale exists: process start, build and release scripts.
|
|
74
|
+
*/
|
|
75
|
+
export function tBoth(key, params = {}) {
|
|
76
|
+
const render = (locale) => Object.entries(params).reduce((text, [name, value]) => {
|
|
77
|
+
const filled = typeof value === "string" ? value : t(locale, value.key);
|
|
78
|
+
return text.replaceAll(`{${name}}`, filled);
|
|
79
|
+
}, t(locale, key));
|
|
80
|
+
return `${render("en")} / ${render("fr")}`;
|
|
81
|
+
}
|
|
70
82
|
function readPath(root, path) {
|
|
71
83
|
let node = root;
|
|
72
84
|
for (const segment of path) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,14 @@
|
|
|
1
|
+
export { ConfigurationError, VARIABLE_TYPES, VariableDeclarationError, defineVariables, describeVariables, loadVariables, } from "./declaredVariables.js";
|
|
2
|
+
export type { BilingualText, BooleanVariable, ConfigurationProblem, DeclaredVariables, EnumVariable, IntegerVariable, JsonContainer, JsonVariable, OriginListVariable, ProblemReason, StringVariable, UrlVariable, VariableDescription, VariableSpec, VariableType, VariablesOf, VariablesSpec, } from "./declaredVariables.js";
|
|
1
3
|
export { createBoilerplateMcpServer } from "./mcpServerFactory.js";
|
|
4
|
+
export { EXAMPLE_ITEMS_READER, LAKE_UNREADABLE_CODE, LakeReadError, checkLakePage, createInMemoryLakeReader, } from "./lake/reader.js";
|
|
5
|
+
export type { LakePage, LakePageRequest, LakeReadErrorCode, LakeReader, LakeScope, } from "./lake/reader.js";
|
|
6
|
+
export { readPage } from "./pagination.js";
|
|
2
7
|
export { buildHttpApp } from "./httpApp.js";
|
|
8
|
+
export type { AppDeps } from "./httpApp.js";
|
|
9
|
+
export { PERSON_UNRESOLVED_CODE, resolveVerifiedPerson, withVerifiedPerson, } from "./verifiedPerson.js";
|
|
10
|
+
export type { CallerResolution, IdentityDeps, PersonUnresolvedReason, VerifiedPerson, } from "./verifiedPerson.js";
|
|
11
|
+
export { UpstreamTokenError, clientCredentialsFromEnv, createClientCredentialsTokenSource, } from "./upstream/clientCredentials.js";
|
|
12
|
+
export type { ClientAuthMethod, ClientCredentialsEnvOptions, ClientCredentialsEnvVars, ClientCredentialsOptions, ClientCredentialsTokenSource, } from "./upstream/clientCredentials.js";
|
|
13
|
+
export { UpstreamPageError, createPaginatedUpstreamClient } from "./upstream/paginatedClient.js";
|
|
14
|
+
export type { FetchLike, PaginatedUpstreamClient, PaginatedUpstreamOptions, PaginationPlacement, ParamObject, ParamValue, UpstreamPage, UpstreamPageErrorCode, UpstreamParams, } from "./upstream/paginatedClient.js";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
|
+
export { ConfigurationError, VARIABLE_TYPES, VariableDeclarationError, defineVariables, describeVariables, loadVariables, } from "./declaredVariables.js";
|
|
1
2
|
export { createBoilerplateMcpServer } from "./mcpServerFactory.js";
|
|
3
|
+
export { EXAMPLE_ITEMS_READER, LAKE_UNREADABLE_CODE, LakeReadError, checkLakePage, createInMemoryLakeReader, } from "./lake/reader.js";
|
|
4
|
+
export { readPage } from "./pagination.js";
|
|
2
5
|
export { buildHttpApp } from "./httpApp.js";
|
|
6
|
+
export { PERSON_UNRESOLVED_CODE, resolveVerifiedPerson, withVerifiedPerson, } from "./verifiedPerson.js";
|
|
7
|
+
export { UpstreamTokenError, clientCredentialsFromEnv, createClientCredentialsTokenSource, } from "./upstream/clientCredentials.js";
|
|
8
|
+
export { UpstreamPageError, createPaginatedUpstreamClient } from "./upstream/paginatedClient.js";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convex adapter of the data-lake reader (src/lake/reader.ts), served at its own subpath:
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { convexLakeReaderFromEnv } from "@vantageos/mcp-boilerplate/lake/convex";
|
|
6
|
+
*
|
|
7
|
+
* const lake = convexLakeReaderFromEnv(
|
|
8
|
+
* process.env,
|
|
9
|
+
* { urlVar: "LAKE_URL", tokenVar: "LAKE_READ_KEY" },
|
|
10
|
+
* {
|
|
11
|
+
* functions: { page: "lakeRead:listRows", get: "lakeRead:getRow" },
|
|
12
|
+
* keyField: "rowKey",
|
|
13
|
+
* tokenArg: "readKey",
|
|
14
|
+
* }
|
|
15
|
+
* );
|
|
16
|
+
* buildHttpApp({ ..., lake });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The variable names, the function names and the key field above are examples: they are
|
|
20
|
+
* CONFIGURATION, chosen by the derived server; nothing here names a table, a function or a client.
|
|
21
|
+
* `convex` is an OPTIONAL peer dependency: only a server importing this subpath needs it.
|
|
22
|
+
*
|
|
23
|
+
* READ-ONLY. The adapter calls `ConvexHttpClient.query` and nothing else — never `mutation`,
|
|
24
|
+
* `action`, `function` or `consistentQuery` (tests/lakeReader.test.ts holds all five as spies). The
|
|
25
|
+
* client type it accepts (`ConvexLakeQueryClient`) has no write method.
|
|
26
|
+
*
|
|
27
|
+
* THE QUERY FUNCTIONS the derived server writes (Convex side), and their arguments:
|
|
28
|
+
* - page `{ after?: string, limit: number, tenantId?: string }` -> an array of at most `limit`
|
|
29
|
+
* rows whose key field is strictly after `after`, ascending by that key. The adapter asks for
|
|
30
|
+
* ONE row more than the page: its presence tells a truncated page from an exhausted one.
|
|
31
|
+
* `tenantId` is present only on a scoped read; the function applies it in its index range.
|
|
32
|
+
* - get `{ key: string, tenantId?: string }` -> the row with that key, or null.
|
|
33
|
+
* With `tokenArg`, every call also carries the read token under that argument name, for a
|
|
34
|
+
* function that checks a shared read key. Without it, the token goes through `setAuth`
|
|
35
|
+
* (`Authorization: Bearer`, which Convex reads as an identity JWT from a configured provider).
|
|
36
|
+
*
|
|
37
|
+
* FAIL CLOSED. An answer that is not a list, a row that is not an object or has no non-empty
|
|
38
|
+
* string key, more rows than asked, keys out of order or not after the cursor, a `get` row with
|
|
39
|
+
* another key, a row the derived server's `parseRow` refuses: each is a `LakeReadError`, never an
|
|
40
|
+
* empty page. A failing call is `read_failed`, and the client's message is NOT forwarded (it can
|
|
41
|
+
* carry the deployment URL or a credential); no `cause` is attached either. No error carries the
|
|
42
|
+
* token, a row value, or the URL.
|
|
43
|
+
*
|
|
44
|
+
* CONNECTION VARIABLES are declared with `defineVariables` (src/declaredVariables.ts): the
|
|
45
|
+
* deployment URL (`url`: https, or http on loopback only) and, when a token variable is named, the
|
|
46
|
+
* token (`secret: true`, required once named). A missing or invalid variable is ONE
|
|
47
|
+
* `ConfigurationError` at start, naming every variable, never a value.
|
|
48
|
+
*/
|
|
49
|
+
import { type FunctionReference } from "convex/server";
|
|
50
|
+
import { type LakeReader } from "./reader.js";
|
|
51
|
+
/** The names of the two Convex query functions to call, e.g. `"module:function"`. */
|
|
52
|
+
export interface ConvexLakeFunctions {
|
|
53
|
+
readonly page: string;
|
|
54
|
+
readonly get: string;
|
|
55
|
+
}
|
|
56
|
+
/** The only client surface the adapter uses. It has no write method, by design. */
|
|
57
|
+
export interface ConvexLakeQueryClient {
|
|
58
|
+
query(fn: FunctionReference<"query">, args: Record<string, unknown>): Promise<unknown>;
|
|
59
|
+
setAuth(token: string): void;
|
|
60
|
+
}
|
|
61
|
+
type RawRow = Readonly<Record<string, unknown>>;
|
|
62
|
+
export interface ConvexLakeReaderOptions<Row = RawRow> {
|
|
63
|
+
/** The deployment URL: https, or http on a loopback host. No query, fragment or credentials. */
|
|
64
|
+
readonly url: string;
|
|
65
|
+
/** The read token. Sent through `setAuth`, or as the `tokenArg` argument when one is named. */
|
|
66
|
+
readonly token?: string;
|
|
67
|
+
/** The argument name the token travels under, for a function checking a shared read key. */
|
|
68
|
+
readonly tokenArg?: string;
|
|
69
|
+
readonly functions: ConvexLakeFunctions;
|
|
70
|
+
/** The row field holding the stable key pages are ordered by. Configuration, no default. */
|
|
71
|
+
readonly keyField: string;
|
|
72
|
+
/**
|
|
73
|
+
* Validates and types one raw row (e.g. a Zod `parse`). It must keep `keyField`. Throwing
|
|
74
|
+
* refuses the row (`malformed_row`); its message is not forwarded. Absent, the row is served as
|
|
75
|
+
* the store returned it, with only its key checked.
|
|
76
|
+
*/
|
|
77
|
+
readonly parseRow?: (raw: RawRow) => Row;
|
|
78
|
+
/** A client to use instead of a `ConvexHttpClient` (a custom transport, a test double). */
|
|
79
|
+
readonly client?: ConvexLakeQueryClient;
|
|
80
|
+
/** A fetch implementation passed to the `ConvexHttpClient` built when `client` is absent. */
|
|
81
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A read-only lake reader over a Convex deployment. Throws on an unreadable configuration (the
|
|
85
|
+
* message names the option, never its value).
|
|
86
|
+
*/
|
|
87
|
+
export declare function createConvexLakeReader<Row = RawRow>(options: ConvexLakeReaderOptions<Row>): LakeReader<Row>;
|
|
88
|
+
/** Which environment variables hold the connection. The derived server names them. */
|
|
89
|
+
export interface ConvexLakeEnvVars {
|
|
90
|
+
/** The deployment URL variable. Required. */
|
|
91
|
+
readonly urlVar: string;
|
|
92
|
+
/** The read token variable. Optional; once named, the variable is required. */
|
|
93
|
+
readonly tokenVar?: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Reads the connection from the variables the derived server names, declared with
|
|
97
|
+
* `defineVariables`, then builds the reader. Throws ONE `ConfigurationError` naming every missing
|
|
98
|
+
* or invalid variable (never a value) before any client is built.
|
|
99
|
+
*/
|
|
100
|
+
export declare function convexLakeReaderFromEnv<Row = RawRow>(env: NodeJS.ProcessEnv, names: ConvexLakeEnvVars, options: Omit<ConvexLakeReaderOptions<Row>, "url" | "token">): LakeReader<Row>;
|
|
101
|
+
export {};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convex adapter of the data-lake reader (src/lake/reader.ts), served at its own subpath:
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { convexLakeReaderFromEnv } from "@vantageos/mcp-boilerplate/lake/convex";
|
|
6
|
+
*
|
|
7
|
+
* const lake = convexLakeReaderFromEnv(
|
|
8
|
+
* process.env,
|
|
9
|
+
* { urlVar: "LAKE_URL", tokenVar: "LAKE_READ_KEY" },
|
|
10
|
+
* {
|
|
11
|
+
* functions: { page: "lakeRead:listRows", get: "lakeRead:getRow" },
|
|
12
|
+
* keyField: "rowKey",
|
|
13
|
+
* tokenArg: "readKey",
|
|
14
|
+
* }
|
|
15
|
+
* );
|
|
16
|
+
* buildHttpApp({ ..., lake });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The variable names, the function names and the key field above are examples: they are
|
|
20
|
+
* CONFIGURATION, chosen by the derived server; nothing here names a table, a function or a client.
|
|
21
|
+
* `convex` is an OPTIONAL peer dependency: only a server importing this subpath needs it.
|
|
22
|
+
*
|
|
23
|
+
* READ-ONLY. The adapter calls `ConvexHttpClient.query` and nothing else — never `mutation`,
|
|
24
|
+
* `action`, `function` or `consistentQuery` (tests/lakeReader.test.ts holds all five as spies). The
|
|
25
|
+
* client type it accepts (`ConvexLakeQueryClient`) has no write method.
|
|
26
|
+
*
|
|
27
|
+
* THE QUERY FUNCTIONS the derived server writes (Convex side), and their arguments:
|
|
28
|
+
* - page `{ after?: string, limit: number, tenantId?: string }` -> an array of at most `limit`
|
|
29
|
+
* rows whose key field is strictly after `after`, ascending by that key. The adapter asks for
|
|
30
|
+
* ONE row more than the page: its presence tells a truncated page from an exhausted one.
|
|
31
|
+
* `tenantId` is present only on a scoped read; the function applies it in its index range.
|
|
32
|
+
* - get `{ key: string, tenantId?: string }` -> the row with that key, or null.
|
|
33
|
+
* With `tokenArg`, every call also carries the read token under that argument name, for a
|
|
34
|
+
* function that checks a shared read key. Without it, the token goes through `setAuth`
|
|
35
|
+
* (`Authorization: Bearer`, which Convex reads as an identity JWT from a configured provider).
|
|
36
|
+
*
|
|
37
|
+
* FAIL CLOSED. An answer that is not a list, a row that is not an object or has no non-empty
|
|
38
|
+
* string key, more rows than asked, keys out of order or not after the cursor, a `get` row with
|
|
39
|
+
* another key, a row the derived server's `parseRow` refuses: each is a `LakeReadError`, never an
|
|
40
|
+
* empty page. A failing call is `read_failed`, and the client's message is NOT forwarded (it can
|
|
41
|
+
* carry the deployment URL or a credential); no `cause` is attached either. No error carries the
|
|
42
|
+
* token, a row value, or the URL.
|
|
43
|
+
*
|
|
44
|
+
* CONNECTION VARIABLES are declared with `defineVariables` (src/declaredVariables.ts): the
|
|
45
|
+
* deployment URL (`url`: https, or http on loopback only) and, when a token variable is named, the
|
|
46
|
+
* token (`secret: true`, required once named). A missing or invalid variable is ONE
|
|
47
|
+
* `ConfigurationError` at start, naming every variable, never a value.
|
|
48
|
+
*/
|
|
49
|
+
import { ConvexHttpClient } from "convex/browser";
|
|
50
|
+
import { makeFunctionReference } from "convex/server";
|
|
51
|
+
import { defineVariables, isSecureUrl, loadVariables } from "../declaredVariables.js";
|
|
52
|
+
import { LakeReadError, assertOrderedKeys } from "./reader.js";
|
|
53
|
+
const ARGUMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
54
|
+
function configurationError(message) {
|
|
55
|
+
return new Error(`convex lake reader: ${message}`);
|
|
56
|
+
}
|
|
57
|
+
/** Refuses an unreadable configuration. Messages name the option, never its value. */
|
|
58
|
+
function checkOptions(options) {
|
|
59
|
+
if (typeof options.url !== "string" || !isSecureUrl(options.url)) {
|
|
60
|
+
throw configurationError("url must be https, or http on a loopback host (value not shown)");
|
|
61
|
+
}
|
|
62
|
+
const url = new URL(options.url);
|
|
63
|
+
if (url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") {
|
|
64
|
+
throw configurationError("url must carry no query, fragment or credentials: the token travels apart (value not shown)");
|
|
65
|
+
}
|
|
66
|
+
for (const [role, name] of [
|
|
67
|
+
["functions.page", options.functions?.page],
|
|
68
|
+
["functions.get", options.functions?.get],
|
|
69
|
+
]) {
|
|
70
|
+
if (typeof name !== "string" || name.trim() === "" || /\s/.test(name)) {
|
|
71
|
+
throw configurationError(`${role} must be a Convex function name such as "module:function"`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (typeof options.keyField !== "string" || options.keyField === "") {
|
|
75
|
+
throw configurationError("keyField must be a non-empty field name");
|
|
76
|
+
}
|
|
77
|
+
if (options.token !== undefined && (typeof options.token !== "string" || options.token === "")) {
|
|
78
|
+
throw configurationError("token, when given, must be a non-empty string (value not shown)");
|
|
79
|
+
}
|
|
80
|
+
if (options.tokenArg !== undefined) {
|
|
81
|
+
if (typeof options.tokenArg !== "string" || !ARGUMENT_NAME.test(options.tokenArg)) {
|
|
82
|
+
throw configurationError("tokenArg must be an argument name ([A-Za-z_][A-Za-z0-9_]*)");
|
|
83
|
+
}
|
|
84
|
+
if (options.token === undefined)
|
|
85
|
+
throw configurationError("tokenArg is named but no token");
|
|
86
|
+
if (["after", "limit", "tenantId", "key"].includes(options.tokenArg)) {
|
|
87
|
+
throw configurationError("tokenArg collides with an argument the adapter sends");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ConvexHttpClient appends `/api/...` to the address as given.
|
|
91
|
+
return options.url.replace(/\/+$/, "");
|
|
92
|
+
}
|
|
93
|
+
/** A ConvexHttpClient seen through the read-only surface. Its constructor errors name the URL. */
|
|
94
|
+
function httpClient(url, fetchImpl) {
|
|
95
|
+
let http;
|
|
96
|
+
try {
|
|
97
|
+
http = new ConvexHttpClient(url, { logger: false, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw configurationError("the Convex client refused the deployment url (value not shown)");
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
query: (fn, args) => http.query(fn, args),
|
|
104
|
+
setAuth: (token) => http.setAuth(token),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function isRawRow(value) {
|
|
108
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A read-only lake reader over a Convex deployment. Throws on an unreadable configuration (the
|
|
112
|
+
* message names the option, never its value).
|
|
113
|
+
*/
|
|
114
|
+
export function createConvexLakeReader(options) {
|
|
115
|
+
const url = checkOptions(options);
|
|
116
|
+
const { functions, keyField, token, tokenArg, parseRow } = options;
|
|
117
|
+
const client = options.client ?? httpClient(url, options.fetch);
|
|
118
|
+
if (token !== undefined && tokenArg === undefined)
|
|
119
|
+
client.setAuth(token);
|
|
120
|
+
const refs = {
|
|
121
|
+
page: makeFunctionReference(functions.page),
|
|
122
|
+
get: makeFunctionReference(functions.get),
|
|
123
|
+
};
|
|
124
|
+
// The token as an argument, built per call so it is held only in this closure.
|
|
125
|
+
const withToken = (args) => tokenArg !== undefined && token !== undefined ? { ...args, [tokenArg]: token } : args;
|
|
126
|
+
const scoped = (scope) => scope === undefined ? {} : { tenantId: scope.tenantId };
|
|
127
|
+
const call = async (which, args) => {
|
|
128
|
+
try {
|
|
129
|
+
return await client.query(refs[which], withToken(args));
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
// The client's message is dropped: it can carry the URL or a credential.
|
|
133
|
+
const kind = error instanceof Error ? ` (${error.name})` : "";
|
|
134
|
+
throw new LakeReadError("read_failed", `query ${functions[which]} failed${kind}`);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
// One raw row -> its key and the served row. Messages name the function and the key field only.
|
|
138
|
+
const readRow = (raw, fn) => {
|
|
139
|
+
if (!isRawRow(raw))
|
|
140
|
+
throw new LakeReadError("malformed_row", `${fn} returned a non-object row`);
|
|
141
|
+
const key = raw[keyField];
|
|
142
|
+
if (typeof key !== "string" || key === "") {
|
|
143
|
+
throw new LakeReadError("malformed_row", `${fn} returned a row whose "${keyField}" is not a non-empty string`);
|
|
144
|
+
}
|
|
145
|
+
if (parseRow === undefined)
|
|
146
|
+
return { key, row: raw };
|
|
147
|
+
let row;
|
|
148
|
+
try {
|
|
149
|
+
row = parseRow(raw);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new LakeReadError("malformed_row", `${fn} returned a row the row parser refused`);
|
|
153
|
+
}
|
|
154
|
+
if (!isRawRow(row) || row[keyField] !== key) {
|
|
155
|
+
throw new LakeReadError("malformed_row", `the row parser did not keep "${keyField}"`);
|
|
156
|
+
}
|
|
157
|
+
return { key, row };
|
|
158
|
+
};
|
|
159
|
+
const reader = {
|
|
160
|
+
keyOf(row) {
|
|
161
|
+
const key = isRawRow(row) ? row[keyField] : undefined;
|
|
162
|
+
if (typeof key !== "string" || key === "") {
|
|
163
|
+
throw new LakeReadError("malformed_row", `a row has no "${keyField}" key`);
|
|
164
|
+
}
|
|
165
|
+
return key;
|
|
166
|
+
},
|
|
167
|
+
async listPage({ after, limit, scope }) {
|
|
168
|
+
const asked = limit + 1;
|
|
169
|
+
const raw = await call("page", {
|
|
170
|
+
...(after === undefined ? {} : { after }),
|
|
171
|
+
limit: asked,
|
|
172
|
+
...scoped(scope),
|
|
173
|
+
});
|
|
174
|
+
if (!Array.isArray(raw)) {
|
|
175
|
+
throw new LakeReadError("malformed_page", `${functions.page} did not return a list`);
|
|
176
|
+
}
|
|
177
|
+
if (raw.length > asked) {
|
|
178
|
+
throw new LakeReadError("malformed_page", `${functions.page} returned more rows than asked`);
|
|
179
|
+
}
|
|
180
|
+
const read = raw.map((r) => readRow(r, functions.page));
|
|
181
|
+
assertOrderedKeys(read.map((r) => r.key), after);
|
|
182
|
+
return {
|
|
183
|
+
rows: read.slice(0, limit).map((r) => r.row),
|
|
184
|
+
exhausted: read.length <= limit,
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
async get(key, scope) {
|
|
188
|
+
const raw = await call("get", { key, ...scoped(scope) });
|
|
189
|
+
if (raw === null || raw === undefined)
|
|
190
|
+
return undefined;
|
|
191
|
+
const read = readRow(raw, functions.get);
|
|
192
|
+
if (read.key !== key) {
|
|
193
|
+
throw new LakeReadError("malformed_row", `${functions.get} returned a row with another key`);
|
|
194
|
+
}
|
|
195
|
+
return read.row;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
return Object.freeze(reader);
|
|
199
|
+
}
|
|
200
|
+
const URL_DESCRIPTION = {
|
|
201
|
+
en: "Convex deployment URL of the data lake (https, or http on a loopback host)",
|
|
202
|
+
fr: "URL du déploiement Convex du data-lake (https, ou http sur une adresse de bouclage)",
|
|
203
|
+
};
|
|
204
|
+
const TOKEN_DESCRIPTION = {
|
|
205
|
+
en: "Read token of the data lake",
|
|
206
|
+
fr: "Jeton de lecture du data-lake",
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Reads the connection from the variables the derived server names, declared with
|
|
210
|
+
* `defineVariables`, then builds the reader. Throws ONE `ConfigurationError` naming every missing
|
|
211
|
+
* or invalid variable (never a value) before any client is built.
|
|
212
|
+
*/
|
|
213
|
+
export function convexLakeReaderFromEnv(env, names, options) {
|
|
214
|
+
const url = { env: names.urlVar, type: "url", description: URL_DESCRIPTION };
|
|
215
|
+
if (names.tokenVar === undefined) {
|
|
216
|
+
const settings = loadVariables(defineVariables({ url }), env);
|
|
217
|
+
return createConvexLakeReader({ ...options, url: settings.url });
|
|
218
|
+
}
|
|
219
|
+
const settings = loadVariables(defineVariables({
|
|
220
|
+
url,
|
|
221
|
+
token: { env: names.tokenVar, type: "string", secret: true, description: TOKEN_DESCRIPTION },
|
|
222
|
+
}), env);
|
|
223
|
+
return createConvexLakeReader({ ...options, url: settings.url, token: settings.token });
|
|
224
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data-lake reader: the READ-ONLY interface through which a server derived from this boilerplate
|
|
3
|
+
* reads its client's data lake (the single persisted source). The tools never know which store is
|
|
4
|
+
* behind it; the store is chosen by injection (`AppDeps.lake` / `McpServerDeps.lake`).
|
|
5
|
+
*
|
|
6
|
+
* The contract mirrors the cursor contract of src/pagination.ts:
|
|
7
|
+
* - rows are keyed by a STABLE, non-empty string key (`keyOf`), served in strictly ascending key
|
|
8
|
+
* order, so the last key served is the cursor;
|
|
9
|
+
* - `listPage({ after, limit, scope })` returns at most `limit` rows whose key is strictly after
|
|
10
|
+
* `after` (all rows when it is undefined), and `exhausted: true` exactly when no row follows
|
|
11
|
+
* them (an exact fit is exhausted, never a false truncation); a truncated page has at least one
|
|
12
|
+
* row;
|
|
13
|
+
* - `scope` (the caller's resolved tenant) is applied INSIDE the range read — a compound
|
|
14
|
+
* (tenant, key) index — never as a filter after the limit, which would shorten a page and make
|
|
15
|
+
* its cursor lie;
|
|
16
|
+
* - `get(key, scope)` returns the row with that key, or undefined.
|
|
17
|
+
*
|
|
18
|
+
* What a derived server adds: its own filters (a status, a date window, a person's portfolio) as
|
|
19
|
+
* arguments of its own adapter or query functions, and its own row type. What is out of scope, by
|
|
20
|
+
* design: writes. The interface has no write method, and the Convex adapter (src/lake/convex.ts)
|
|
21
|
+
* only ever calls queries.
|
|
22
|
+
*
|
|
23
|
+
* Fail closed: `readPage` (src/pagination.ts) checks every page against this contract with
|
|
24
|
+
* `checkLakePage`; a page it cannot read is a `LakeReadError`, never an empty or partial page.
|
|
25
|
+
* Its message names the contract clause broken, never a row value.
|
|
26
|
+
*
|
|
27
|
+
* The in-memory adapter below wraps the boilerplate's seed (src/exampleItems.ts) and is the
|
|
28
|
+
* default reader, so a server with no lake injected behaves exactly as before.
|
|
29
|
+
*/
|
|
30
|
+
import { type ExampleItem } from "../exampleItems.js";
|
|
31
|
+
import { type TenantOwned } from "../tenancy.js";
|
|
32
|
+
/** The caller's resolved tenant, applied inside the range read. */
|
|
33
|
+
export interface LakeScope {
|
|
34
|
+
readonly tenantId: string;
|
|
35
|
+
}
|
|
36
|
+
export interface LakePageRequest {
|
|
37
|
+
/** The last key already served; omitted for the first page. */
|
|
38
|
+
readonly after?: string;
|
|
39
|
+
/** At most this many rows. A positive integer, already bounded by the tool. */
|
|
40
|
+
readonly limit: number;
|
|
41
|
+
readonly scope?: LakeScope;
|
|
42
|
+
}
|
|
43
|
+
export interface LakePage<Row> {
|
|
44
|
+
readonly rows: readonly Row[];
|
|
45
|
+
/** True exactly when no row follows the ones returned. */
|
|
46
|
+
readonly exhausted: boolean;
|
|
47
|
+
}
|
|
48
|
+
/** Read-only by design: there is no write method. */
|
|
49
|
+
export interface LakeReader<Row> {
|
|
50
|
+
/** The row's stable key, the one pages are ordered by and cursors carry. */
|
|
51
|
+
keyOf(row: Row): string;
|
|
52
|
+
listPage(request: LakePageRequest): Promise<LakePage<Row>>;
|
|
53
|
+
get(key: string, scope?: LakeScope): Promise<Row | undefined>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Every way a read can fail:
|
|
57
|
+
* - `malformed_page`: the page breaks the contract (not a list, more rows than asked, keys out of
|
|
58
|
+
* order or not after the cursor, a truncated page with no row);
|
|
59
|
+
* - `malformed_row`: a row has no readable key, or the derived server's row parser refused it;
|
|
60
|
+
* - `out_of_scope`: a row of another tenant came back from a scoped read;
|
|
61
|
+
* - `read_failed`: the store call itself failed (its message is not forwarded: it may carry a
|
|
62
|
+
* URL or a credential).
|
|
63
|
+
*/
|
|
64
|
+
export type LakeReadErrorCode = "malformed_page" | "malformed_row" | "out_of_scope" | "read_failed";
|
|
65
|
+
/** The stable machine code of a tool result refused because the lake answer was unreadable. */
|
|
66
|
+
export declare const LAKE_UNREADABLE_CODE = "lake_unreadable";
|
|
67
|
+
export declare class LakeReadError extends Error {
|
|
68
|
+
readonly code: LakeReadErrorCode;
|
|
69
|
+
constructor(code: LakeReadErrorCode, message: string);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Checks that `keys` are strictly ascending and all strictly after `after`. Shared by
|
|
73
|
+
* `checkLakePage` and by adapters that read a raw answer.
|
|
74
|
+
*/
|
|
75
|
+
export declare function assertOrderedKeys(keys: readonly string[], after: string | undefined): void;
|
|
76
|
+
/** Checks a page against the contract above; returns it, or throws a `LakeReadError`. */
|
|
77
|
+
export declare function checkLakePage<Row>(reader: Pick<LakeReader<Row>, "keyOf">, request: LakePageRequest, page: unknown): LakePage<Row>;
|
|
78
|
+
/**
|
|
79
|
+
* An in-memory reader over rows SORTED strictly ascending by `id` (refused otherwise, at
|
|
80
|
+
* 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
|
|
82
|
+
* shortened afterwards. A store-backed reader reads the same range from a (tenant, key) index.
|
|
83
|
+
*/
|
|
84
|
+
export declare function createInMemoryLakeReader<Row extends {
|
|
85
|
+
id: string;
|
|
86
|
+
} & TenantOwned>(sortedRows: readonly Row[]): LakeReader<Row>;
|
|
87
|
+
/** The default reader behind `list_items`: the in-memory seed. */
|
|
88
|
+
export declare const EXAMPLE_ITEMS_READER: LakeReader<ExampleItem>;
|