@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/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 { createBoilerplateMcpServer } from "./mcpServerFactory.js";
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
- // Unauthenticated (S-7): the version AND the identity of the tree being served (C-25), so a
98
- // deployment is proven from what the server returns, not from what the deploy printed.
99
- app.get("/health", (c) => c.json({ status: "ok", version: SERVER_VERSION, buildId: getBuildIdentity() }));
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 and the OAuth/discovery routes (/.well-known/*, /authorize, /token,
108
- // /register) are NOT covered — they are browser navigations or CORS calls of the sign-in flow
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 { readFileSync } from "node:fs";
22
- import { fileURLToPath } from "node:url";
23
- // Loaded via fs + JSON.parse, not a JSON module import: Node's ESM loader
24
- // (the runtime the compiled dist/stdio.js and dist/http.js run under)
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: loadLocaleResource("en.json"),
37
- fr: loadLocaleResource("fr.json"),
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.
@@ -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`
@@ -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`);
@@ -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 {
@@ -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, "&amp;")
44
+ .replace(/</g, "&lt;")
45
+ .replace(/>/g, "&gt;")
46
+ .replace(/"/g, "&quot;")
47
+ .replace(/'/g, "&#39;");
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"];