@vantageos/mcp-boilerplate 0.7.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 +18 -0
- package/README.fr.md +57 -5
- package/README.md +55 -5
- package/dist/build-info.json +1 -0
- package/dist/buildIdentity.d.ts +16 -0
- package/dist/buildIdentity.js +39 -0
- package/dist/embedded.generated.d.ts +225 -0
- package/dist/embedded.generated.js +8 -0
- package/dist/http.js +5 -3
- package/dist/httpApp.d.ts +6 -0
- package/dist/httpApp.js +64 -7
- package/dist/i18n.js +6 -16
- 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/dist/landingPage.d.ts +24 -0
- package/dist/landingPage.js +109 -0
- package/dist/mcpServerFactory.d.ts +11 -0
- package/dist/mcpServerFactory.js +28 -7
- package/dist/servedToolRecords.d.ts +6 -0
- package/dist/servedToolRecords.js +11 -1
- package/dist/serverCard.d.ts +68 -0
- package/dist/serverCard.js +68 -0
- package/dist/serverIcon.d.ts +17 -0
- package/dist/serverIcon.js +26 -0
- package/dist/serverIdentity.d.ts +83 -0
- package/dist/serverIdentity.js +130 -0
- package/dist/ui/uiResource.d.ts +14 -1
- package/dist/ui/uiResource.js +11 -1
- package/dist/version.js +6 -5
- package/docs/manufact-gap.md +200 -0
- package/mcp-server.json +1 -1
- package/package.json +3 -2
- package/plugin.json +2 -4
- package/scripts/readme-fr.mjs +8 -3
- package/dist/locales/en.json +0 -88
- package/dist/locales/fr.json +0 -88
package/dist/httpApp.js
CHANGED
|
@@ -26,18 +26,28 @@ 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
|
+
import { getBuildCommit, getBuildIdentity } from "./buildIdentity.js";
|
|
30
30
|
import { registerHostVerification } from "./hostVerification.js";
|
|
31
31
|
import { detectLocale, t, tBoth } from "./i18n.js";
|
|
32
|
-
import {
|
|
32
|
+
import { isBrowserLandingRequest, landingPageResponse } from "./landingPage.js";
|
|
33
|
+
import { createBoilerplateMcpServer, registeredToolSummaries } from "./mcpServerFactory.js";
|
|
33
34
|
import { createAuthorizationServer, } from "./oauth/authorizationServer.js";
|
|
34
35
|
import { MCP_SCOPE } from "./oauth/settings.js";
|
|
36
|
+
import { SERVER_CARD_PATHS, buildServerCard, serverCardResponse } from "./serverCard.js";
|
|
37
|
+
import { SERVER_ICON_MIME_TYPE, SERVER_ICON_PATH, SERVER_ICON_SVG } from "./serverIcon.js";
|
|
38
|
+
import { getServerIdentity } from "./serverIdentity.js";
|
|
35
39
|
import { loadServerManifest } from "./serverManifest.js";
|
|
36
40
|
import { resolveTenantFromJwtPayload } from "./tenancy.js";
|
|
37
41
|
import { callerScopeOf } from "./toolRoleGate.js";
|
|
38
42
|
import { ToolRolesConfigError, loadToolRoles } from "./toolRoles.js";
|
|
43
|
+
import { servedMcpUrl } from "./ui/sandboxDomain.js";
|
|
39
44
|
import { assertIdentityDeps, resolveVerifiedPerson, } from "./verifiedPerson.js";
|
|
40
45
|
import { SERVER_VERSION } from "./version.js";
|
|
46
|
+
function servedLocation(publicBaseUrl, requestUrl) {
|
|
47
|
+
const configured = publicBaseUrl ? servedMcpUrl({ PUBLIC_BASE_URL: publicBaseUrl }) : undefined;
|
|
48
|
+
const mcpUrl = configured ?? new URL("/mcp", requestUrl).href;
|
|
49
|
+
return { mcpUrl, baseUrl: new URL("./", mcpUrl).href, configured: configured !== undefined };
|
|
50
|
+
}
|
|
41
51
|
const PRM_PATHS = [
|
|
42
52
|
"/.well-known/oauth-protected-resource",
|
|
43
53
|
"/.well-known/oauth-protected-resource/mcp",
|
|
@@ -94,9 +104,45 @@ export function buildHttpApp(deps = {}) {
|
|
|
94
104
|
return config;
|
|
95
105
|
})()
|
|
96
106
|
: { applicable: false };
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
107
|
+
// The public identity (S-49), read once at start: a value neither the environment nor
|
|
108
|
+
// package.json carries stops the server here, naming it, instead of serving a placeholder.
|
|
109
|
+
const identity = getServerIdentity();
|
|
110
|
+
// Unauthenticated (S-7): the version AND the identity of the tree being served (C-25): buildId is
|
|
111
|
+
// the git commit the tree was built from, buildHash the hash of the files this process runs
|
|
112
|
+
// from, so a deployment is proven from what the server returns, not from what the deploy printed.
|
|
113
|
+
app.get("/health", (c) => c.json({
|
|
114
|
+
status: "ok",
|
|
115
|
+
version: SERVER_VERSION,
|
|
116
|
+
buildId: getBuildCommit(),
|
|
117
|
+
buildHash: getBuildIdentity(),
|
|
118
|
+
}));
|
|
119
|
+
// Discoverability (S-49 icon, S-50), public and registered BEFORE the auth gate below: a route
|
|
120
|
+
// that answers ends the chain, so none of them is ever gated. They read no credential and grant
|
|
121
|
+
// nothing. The setup page at /mcp sits after the Origin check (S-22 holds on every /mcp method).
|
|
122
|
+
// - the serverInfo icon, as SVG, under a CSP that forbids script and external references;
|
|
123
|
+
app.get(`/${SERVER_ICON_PATH}`, (c) => c.body(SERVER_ICON_SVG, 200, {
|
|
124
|
+
"content-type": `${SERVER_ICON_MIME_TYPE}; charset=utf-8`,
|
|
125
|
+
"cache-control": "public, max-age=86400",
|
|
126
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
|
|
127
|
+
"x-content-type-options": "nosniff",
|
|
128
|
+
}));
|
|
129
|
+
// - the MCP Server Card at every path a scanner probes (src/serverCard.ts), its tool summary
|
|
130
|
+
// read from the tools a server built by the factory registers (computed once, on first read);
|
|
131
|
+
let cardTools;
|
|
132
|
+
for (const path of SERVER_CARD_PATHS) {
|
|
133
|
+
app.get(path, (c) => {
|
|
134
|
+
cardTools ??= registeredToolSummaries(createBoilerplateMcpServer({ locale: "en" }));
|
|
135
|
+
const where = servedLocation(deps.publicBaseUrl, c.req.url);
|
|
136
|
+
const card = buildServerCard({
|
|
137
|
+
identity,
|
|
138
|
+
mcpUrl: where.mcpUrl,
|
|
139
|
+
baseUrl: where.baseUrl,
|
|
140
|
+
capabilities: manifest.capabilities,
|
|
141
|
+
tools: [...cardTools],
|
|
142
|
+
});
|
|
143
|
+
return serverCardResponse(c.req.raw, card, where.configured);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
100
146
|
// S-45: the verification path of every declared target host, served from configuration.
|
|
101
147
|
registerHostVerification(app, manifest.distribution.targets);
|
|
102
148
|
// S-22 Origin validation, on /mcp ONLY and registered BEFORE the auth gate: a request that carries
|
|
@@ -104,8 +150,9 @@ export function buildHttpApp(deps = {}) {
|
|
|
104
150
|
// insensitive), else 403 before any token is looked at. No Origin header (server-to-server, Claude
|
|
105
151
|
// Code, a connector backend) passes as before. `null`, an empty value and near-misses such as
|
|
106
152
|
// `https://claude.ai.evil.example` are not in the list, so they are refused.
|
|
107
|
-
// Declared divergence: /health
|
|
108
|
-
//
|
|
153
|
+
// Declared divergence: /health, the icon, the server card paths (/mcp/server-card included, served
|
|
154
|
+
// above) and the OAuth/discovery routes (/.well-known/*, /authorize, /token, /register) are NOT
|
|
155
|
+
// covered — they are public metadata, browser navigations or CORS calls of the sign-in flow
|
|
109
156
|
// (/register has its own MCP_REGISTER_CORS_ORIGINS allowlist) and S-22 guards the MCP endpoint.
|
|
110
157
|
const allowedOrigins = new Set((deps.allowedOrigins ?? []).map((o) => o.toLowerCase()));
|
|
111
158
|
app.use("/mcp", async (c, next) => {
|
|
@@ -118,6 +165,15 @@ export function buildHttpApp(deps = {}) {
|
|
|
118
165
|
message_fr: t("fr", "auth.originNotAllowed"),
|
|
119
166
|
}, 403);
|
|
120
167
|
});
|
|
168
|
+
// S-50 setup page at the MCP URL, after the Origin check and BEFORE the auth gate: a request that
|
|
169
|
+
// cannot be an MCP request (a browser GET: no Authorization, no text/event-stream) gets the
|
|
170
|
+
// public page (src/landingPage.ts); every other request continues to the gate unchanged.
|
|
171
|
+
app.get("/mcp", async (c, next) => {
|
|
172
|
+
if (!isBrowserLandingRequest(c.req.raw))
|
|
173
|
+
return next();
|
|
174
|
+
const where = servedLocation(deps.publicBaseUrl, c.req.url);
|
|
175
|
+
return landingPageResponse(identity, where.mcpUrl, authMode, where.configured);
|
|
176
|
+
});
|
|
121
177
|
if (authMode === "oauth") {
|
|
122
178
|
// Resolves the resource-server configuration per request: the own-AS tier reads its
|
|
123
179
|
// environment lazily, so a missing value is a 503 on the request, not a crash at boot.
|
|
@@ -211,6 +267,7 @@ export function buildHttpApp(deps = {}) {
|
|
|
211
267
|
caller,
|
|
212
268
|
locale,
|
|
213
269
|
uiDomain: deps.uiDomain,
|
|
270
|
+
baseUrl: servedLocation(deps.publicBaseUrl, c.req.url).baseUrl,
|
|
214
271
|
lake: deps.lake,
|
|
215
272
|
cursorSigner: deps.cursorSigner,
|
|
216
273
|
rateLimiter: deps.rateLimiter,
|
package/dist/i18n.js
CHANGED
|
@@ -18,23 +18,13 @@
|
|
|
18
18
|
* (§Bilingual, l.234): "fr" selects the French resource, anything else
|
|
19
19
|
* (including a missing header) defaults to English.
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// requires an explicit import attribute for a JSON module import, which the
|
|
26
|
-
// project's current `tsconfig.json` `module` target does not emit. This
|
|
27
|
-
// project's `resolveJsonModule` + a plain JSON import already work for
|
|
28
|
-
// bundler-processed code (e.g. src/ui/Widget.tsx, inlined by Vite at build
|
|
29
|
-
// time and never hitting Node's loader at all) but not for tsc's own Node
|
|
30
|
-
// ESM output, so the two resources here are read as plain files instead.
|
|
31
|
-
function loadLocaleResource(fileName) {
|
|
32
|
-
const path = fileURLToPath(new URL(`./locales/${fileName}`, import.meta.url));
|
|
33
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
-
}
|
|
21
|
+
import { EMBEDDED_LOCALES } from "./embedded.generated.js";
|
|
22
|
+
// The two resources are module values (src/embedded.generated.ts, generated from src/locales/{en,fr}.json by
|
|
23
|
+
// scripts/embed-static.mjs at build), never a file read at import: a bundled consumer (the Convex bundler,
|
|
24
|
+
// esbuild) carries this module and no `locales/` folder beside it (task k17aj33ks3ndyxgt7jch68rpwx8hz9x5).
|
|
35
25
|
const RESOURCES = {
|
|
36
|
-
en:
|
|
37
|
-
fr:
|
|
26
|
+
en: EMBEDDED_LOCALES.en,
|
|
27
|
+
fr: EMBEDDED_LOCALES.fr,
|
|
38
28
|
};
|
|
39
29
|
/**
|
|
40
30
|
* Resolves the caller's locale from the `X-MCP-Locale` request header.
|
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) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public setup page at the MCP URL (mcp-server-standard S-50, Manufact "Setup instructions at
|
|
3
|
+
* the MCP URL"). A person who pastes the MCP URL into a browser gets a short bilingual page
|
|
4
|
+
* saying what the server is and how to connect, instead of a bare 405 or 401.
|
|
5
|
+
*
|
|
6
|
+
* The page is static text built from src/serverIdentity.ts and the locale files: it reads no
|
|
7
|
+
* credential, calls no backend and grants nothing. Only a request that cannot be an MCP request
|
|
8
|
+
* is answered with it (`isBrowserLandingRequest`); every other request on /mcp goes on to the
|
|
9
|
+
* Origin check, the auth gate and the MCP handler exactly as before, including the
|
|
10
|
+
* 401 + WWW-Authenticate that OAuth discovery depends on.
|
|
11
|
+
*/
|
|
12
|
+
import type { AuthMode } from "./config.js";
|
|
13
|
+
import type { ServerIdentity } from "./serverIdentity.js";
|
|
14
|
+
/**
|
|
15
|
+
* True only for a browser-style GET: no Authorization header, an Accept that does not include
|
|
16
|
+
* text/event-stream, and an Accept that includes text/html, or is exactly `*` `/` `*`, or is
|
|
17
|
+
* absent. RFC 9110 §12.5.1: an absent Accept means any media type; an MCP client opening the SSE
|
|
18
|
+
* stream always sends `Accept: text/event-stream`, so none of these is an MCP request.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isBrowserLandingRequest(req: Request): boolean;
|
|
21
|
+
/** The page for the server whose MCP endpoint is `mcpUrl`: English, then French. */
|
|
22
|
+
export declare function renderLandingPage(identity: ServerIdentity, mcpUrl: string, authMode: AuthMode): string;
|
|
23
|
+
/** The setup page as an HTTP response. `cacheable`: see serverCardResponse (src/serverCard.ts). */
|
|
24
|
+
export declare function landingPageResponse(identity: ServerIdentity, mcpUrl: string, authMode: AuthMode, cacheable: boolean): Response;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public setup page at the MCP URL (mcp-server-standard S-50, Manufact "Setup instructions at
|
|
3
|
+
* the MCP URL"). A person who pastes the MCP URL into a browser gets a short bilingual page
|
|
4
|
+
* saying what the server is and how to connect, instead of a bare 405 or 401.
|
|
5
|
+
*
|
|
6
|
+
* The page is static text built from src/serverIdentity.ts and the locale files: it reads no
|
|
7
|
+
* credential, calls no backend and grants nothing. Only a request that cannot be an MCP request
|
|
8
|
+
* is answered with it (`isBrowserLandingRequest`); every other request on /mcp goes on to the
|
|
9
|
+
* Origin check, the auth gate and the MCP handler exactly as before, including the
|
|
10
|
+
* 401 + WWW-Authenticate that OAuth discovery depends on.
|
|
11
|
+
*/
|
|
12
|
+
import { t } from "./i18n.js";
|
|
13
|
+
/** The media ranges of an Accept header, lower-cased, parameters dropped. */
|
|
14
|
+
function mediaRanges(accept) {
|
|
15
|
+
return accept
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((part) => (part.split(";")[0] ?? "").trim().toLowerCase())
|
|
18
|
+
.filter((part) => part.length > 0);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* True only for a browser-style GET: no Authorization header, an Accept that does not include
|
|
22
|
+
* text/event-stream, and an Accept that includes text/html, or is exactly `*` `/` `*`, or is
|
|
23
|
+
* absent. RFC 9110 §12.5.1: an absent Accept means any media type; an MCP client opening the SSE
|
|
24
|
+
* stream always sends `Accept: text/event-stream`, so none of these is an MCP request.
|
|
25
|
+
*/
|
|
26
|
+
export function isBrowserLandingRequest(req) {
|
|
27
|
+
if (req.method !== "GET")
|
|
28
|
+
return false;
|
|
29
|
+
if (req.headers.has("authorization"))
|
|
30
|
+
return false;
|
|
31
|
+
const accept = req.headers.get("accept");
|
|
32
|
+
if (accept === null)
|
|
33
|
+
return true;
|
|
34
|
+
const ranges = mediaRanges(accept);
|
|
35
|
+
if (ranges.includes("text/event-stream"))
|
|
36
|
+
return false;
|
|
37
|
+
if (ranges.includes("text/html"))
|
|
38
|
+
return true;
|
|
39
|
+
return ranges.length === 1 && ranges[0] === "*/*";
|
|
40
|
+
}
|
|
41
|
+
function escapeHtml(value) {
|
|
42
|
+
return value
|
|
43
|
+
.replace(/&/g, "&")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/"/g, """)
|
|
47
|
+
.replace(/'/g, "'");
|
|
48
|
+
}
|
|
49
|
+
function section(locale, identity, mcpUrl, authMode) {
|
|
50
|
+
const tx = (key) => escapeHtml(t(locale, `landing.${key}`));
|
|
51
|
+
const endpoint = escapeHtml(mcpUrl);
|
|
52
|
+
const site = escapeHtml(identity.websiteUrl);
|
|
53
|
+
const add = escapeHtml(`claude mcp add --transport http ${identity.name} ${mcpUrl}`);
|
|
54
|
+
return `<section lang="${locale}">
|
|
55
|
+
<h1>${escapeHtml(identity.title)}</h1>
|
|
56
|
+
<p>${escapeHtml(identity.description[locale])}</p>
|
|
57
|
+
<p>${tx("intro")}</p>
|
|
58
|
+
<p><code>${endpoint}</code></p>
|
|
59
|
+
<ul>
|
|
60
|
+
<li>${tx("claudeAi")}</li>
|
|
61
|
+
<li>${tx("chatgpt")}</li>
|
|
62
|
+
<li>${tx("claudeCode")} <code>${add}</code></li>
|
|
63
|
+
<li>${tx("other")}</li>
|
|
64
|
+
</ul>
|
|
65
|
+
<p>${tx(authMode === "oauth" ? "authOauth" : "authPublic")}</p>
|
|
66
|
+
<p>${tx("website")} <a href="${site}">${site}</a></p>
|
|
67
|
+
</section>`;
|
|
68
|
+
}
|
|
69
|
+
/** The page for the server whose MCP endpoint is `mcpUrl`: English, then French. */
|
|
70
|
+
export function renderLandingPage(identity, mcpUrl, authMode) {
|
|
71
|
+
const title = escapeHtml(t("en", "landing.pageTitle").replace("{title}", () => identity.title));
|
|
72
|
+
return `<!doctype html>
|
|
73
|
+
<html lang="en">
|
|
74
|
+
<head>
|
|
75
|
+
<meta charset="utf-8">
|
|
76
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
77
|
+
<title>${title}</title>
|
|
78
|
+
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
|
79
|
+
<style>
|
|
80
|
+
body { font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; max-width: 42rem; margin: 3rem auto; padding: 0 1.25rem; color: #1f2328; background: #ffffff; }
|
|
81
|
+
code { background: #f6f8fa; padding: 0.1rem 0.35rem; border-radius: 4px; overflow-wrap: anywhere; }
|
|
82
|
+
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
|
|
83
|
+
li { margin: 0.4rem 0; }
|
|
84
|
+
section + section { border-top: 1px solid #d0d7de; margin-top: 2rem; padding-top: 1rem; }
|
|
85
|
+
@media (prefers-color-scheme: dark) { body { background: #0d1117; color: #e6edf3; } code { background: #161b22; } a { color: #58a6ff; } section + section { border-color: #30363d; } }
|
|
86
|
+
</style>
|
|
87
|
+
</head>
|
|
88
|
+
<body>
|
|
89
|
+
<main>
|
|
90
|
+
${section("en", identity, mcpUrl, authMode)}
|
|
91
|
+
${section("fr", identity, mcpUrl, authMode)}
|
|
92
|
+
</main>
|
|
93
|
+
</body>
|
|
94
|
+
</html>`;
|
|
95
|
+
}
|
|
96
|
+
/** The setup page as an HTTP response. `cacheable`: see serverCardResponse (src/serverCard.ts). */
|
|
97
|
+
export function landingPageResponse(identity, mcpUrl, authMode, cacheable) {
|
|
98
|
+
return new Response(renderLandingPage(identity, mcpUrl, authMode), {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: {
|
|
101
|
+
"content-type": "text/html; charset=utf-8",
|
|
102
|
+
"cache-control": cacheable ? "public, max-age=300" : "no-store",
|
|
103
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
104
|
+
"x-content-type-options": "nosniff",
|
|
105
|
+
// The same URL answers MCP requests differently by method, Accept and credential.
|
|
106
|
+
vary: "Accept, Authorization",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -33,6 +33,7 @@ import type { ExampleItem } from "./exampleItems.js";
|
|
|
33
33
|
import { type McpLocale } from "./i18n.js";
|
|
34
34
|
import { type LakeReader } from "./lake/reader.js";
|
|
35
35
|
import { type RateLimiter } from "./rateLimit.js";
|
|
36
|
+
import { type ServedToolRecords } from "./servedToolRecords.js";
|
|
36
37
|
import type { CursorSigner } from "./signedCursor.js";
|
|
37
38
|
import { type ResolvedTenant } from "./tenancy.js";
|
|
38
39
|
import { type ToolRolesContext } from "./toolRoleGate.js";
|
|
@@ -66,6 +67,11 @@ export interface McpServerDeps {
|
|
|
66
67
|
* httpApp.ts). Defaults to "en"; the stdio transport (no HTTP headers) uses the default.
|
|
67
68
|
*/
|
|
68
69
|
locale?: McpLocale;
|
|
70
|
+
/**
|
|
71
|
+
* The base URL (ending in `/`) the HTTP transport is reached at, resolved per request by
|
|
72
|
+
* src/httpApp.ts: the serverInfo icon is served under it. Absent (stdio): the icon travels inline.
|
|
73
|
+
*/
|
|
74
|
+
baseUrl?: string;
|
|
69
75
|
/** The tool invocation limiter (S-24); default the process-wide `toolRateLimiter`. A test injects its own. */
|
|
70
76
|
rateLimiter?: RateLimiter;
|
|
71
77
|
/**
|
|
@@ -92,3 +98,8 @@ export interface McpServerDeps {
|
|
|
92
98
|
cursorSigner?: CursorSigner;
|
|
93
99
|
}
|
|
94
100
|
export declare function createBoilerplateMcpServer(deps?: McpServerDeps): McpServer;
|
|
101
|
+
/**
|
|
102
|
+
* The tools a server built by this factory registered (name, title, description), for the public
|
|
103
|
+
* server card (src/serverCard.ts): read from the registrations, never a second list.
|
|
104
|
+
*/
|
|
105
|
+
export declare function registeredToolSummaries(server: McpServer): ServedToolRecords["summaries"];
|