@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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +147 -0
  3. package/README.fr.md +318 -15
  4. package/README.md +302 -15
  5. package/dist/buildIdentity.d.ts +19 -0
  6. package/dist/buildIdentity.js +62 -0
  7. package/dist/declaredVariables.d.ts +205 -0
  8. package/dist/declaredVariables.js +451 -0
  9. package/dist/exampleItems.d.ts +15 -0
  10. package/dist/exampleItems.js +28 -1
  11. package/dist/httpApp.d.ts +17 -0
  12. package/dist/httpApp.js +19 -2
  13. package/dist/i18n.d.ts +10 -0
  14. package/dist/i18n.js +12 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +6 -0
  17. package/dist/lake/convex.d.ts +101 -0
  18. package/dist/lake/convex.js +224 -0
  19. package/dist/lake/reader.d.ts +88 -0
  20. package/dist/lake/reader.js +120 -0
  21. package/dist/locales/en.json +42 -0
  22. package/dist/locales/fr.json +42 -0
  23. package/dist/mcpServerFactory.d.ts +31 -14
  24. package/dist/mcpServerFactory.js +116 -48
  25. package/dist/oauth/authorizationServer.js +63 -18
  26. package/dist/oauth/clerk.d.ts +2 -0
  27. package/dist/oauth/clerk.js +15 -5
  28. package/dist/oauth/clientRegistry.d.ts +19 -4
  29. package/dist/oauth/clientRegistry.js +20 -10
  30. package/dist/oauth/picker.d.ts +5 -2
  31. package/dist/oauth/picker.js +34 -4
  32. package/dist/oauth/registerLimiter.d.ts +34 -0
  33. package/dist/oauth/registerLimiter.js +95 -0
  34. package/dist/oauth/settings.d.ts +2 -0
  35. package/dist/oauth/settings.js +2 -0
  36. package/dist/pagination.d.ts +66 -0
  37. package/dist/pagination.js +100 -0
  38. package/dist/serverManifest.d.ts +32 -0
  39. package/dist/serverManifest.js +68 -0
  40. package/dist/stdio.js +5 -1
  41. package/dist/tenancy.d.ts +1 -1
  42. package/dist/toolRecord.d.ts +94 -0
  43. package/dist/toolRecord.js +52 -0
  44. package/dist/ui/uiResource.d.ts +11 -4
  45. package/dist/ui/uiResource.js +8 -4
  46. package/dist/upstream/clientCredentials.d.ts +106 -0
  47. package/dist/upstream/clientCredentials.js +283 -0
  48. package/dist/upstream/paginatedClient.d.ts +142 -0
  49. package/dist/upstream/paginatedClient.js +422 -0
  50. package/dist/verifiedPerson.d.ts +85 -0
  51. package/dist/verifiedPerson.js +112 -0
  52. package/docs/oauth-stores.md +2 -1
  53. package/examples/oauth.env.example +5 -0
  54. package/mcp-server.json +13 -0
  55. package/package.json +19 -4
  56. package/plugin.json +1 -1
  57. package/skills/connect-mcp-server/SKILL.md +5 -5
package/README.md CHANGED
@@ -9,8 +9,8 @@ stateless Streamable HTTP from one server definition, its **own OAuth 2.1
9
9
  authorization server** (a Clerk person picks an organisation), an MCP Apps UI
10
10
  and FR/EN messages. No custom REST route ever stands in front of a tool call.
11
11
 
12
- It ships three small example tools (`ping`, `list_items`, `render_items_ui`)
13
- that exercise every layer. Replace them with yours; the wiring stays.
12
+ It ships two small example tools (`ping`, `list_items`) that exercise every
13
+ layer; `list_items` also carries the MCP Apps widget. Replace them with yours; the wiring stays.
14
14
 
15
15
  ## Structure
16
16
 
@@ -18,16 +18,24 @@ that exercise every layer. Replace them with yours; the wiring stays.
18
18
  src/
19
19
  mcpServerFactory.ts # single MCP server definition, shared by both transports
20
20
  version.ts # server name + version, derived from package.json
21
+ serverManifest.ts # fail-closed loader of mcp-server.json (the server manifest)
22
+ toolRecord.ts # typed tool record: authority, verb, table, mechanism, pagination, cost
23
+ pagination.ts # cursor pagination applied by the tool (default, finite max, invalid_cursor)
24
+ buildIdentity.ts # build identity derived from the served tree
21
25
  stdio.ts # serveStdio(factory): both protocol eras over stdio
22
26
  http.ts # Node http.Server adapter around the Hono app
23
27
  httpApp.ts # createMcpHandler(factory) on /mcp + the OAuth gate
24
28
  auth.ts # resource-server wiring (@vantageos/cloud-identity)
25
29
  config.ts # env-derived AuthMode + external-issuer AuthConfig
26
30
  tenancy.ts # verified JWT -> tenant; rows filtered by stored orgId
31
+ lake/ # read-only data-lake reader: interface + in-memory default, Convex adapter
32
+ reader.ts convex.ts
27
33
  oauth/ # own authorization server (cloud-identity decides)
28
34
  authorizationServer.ts settings.ts clerk.ts tokenIssuer.ts
29
35
  clientRegistry.ts codeStore.ts picker.ts
30
36
  ui/ # MCP Apps widget + ui:// resource
37
+ mcp-server.json # the server manifest: capabilities, transport, version, feature switches
38
+ .github/workflows/release.yml # npm publish --provenance + registry proof (Actions is disabled fleet-wide)
31
39
  skills/connect-mcp-server/SKILL.md # onboarding skill (English)
32
40
  scripts/
33
41
  plugin-manifest.source.json # the ONE source of every packaging manifest
@@ -73,12 +81,33 @@ server version is read from `package.json`.
73
81
 
74
82
  ## Tool records
75
83
 
76
- Every tool declares `title`, the four annotations as explicit booleans
77
- (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), a typed
78
- input schema and a typed output schema (Zod 4), and returns `structuredContent`
79
- plus a text block with the same JSON. `tests/toolRecordComplete.test.ts` walks
80
- `tools/list` and fails on any gap. Annotations are untrusted hints for the
81
- client (mcp-standard C-15); never gate an action on them alone.
84
+ Every tool declares `title`, a typed input schema and a typed output schema (Zod 4),
85
+ and returns `structuredContent` plus a text block with the same JSON. Its
86
+ `annotations` carry the typed record of `src/toolRecord.ts`, readable from
87
+ `tools/list` without reading the source:
88
+
89
+ | Key | Rule | Meaning |
90
+ |---|---|---|
91
+ | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` | C-15 | the four behavioural booleans |
92
+ | `authRead`, `authWrite` | C-8 | authority pair from `public` / `org-member` / `org-admin` / `fleet-internal` / `master`, distinct; resolved from the verified identity, never from an argument |
93
+ | `verb`, `table` | C-9, C-12 | the operation (`list`, `get`, `create`, `pure`...) and the data it touches |
94
+ | `searchMechanism` | C-10 | how a list/search is served (`indexed-range` here); `unindexed-scan` does not publish |
95
+ | `paginationCursor`, `paginationDefault`, `paginationMax` | C-11 | cursor, default page size and a finite maximum, applied by `src/pagination.ts` |
96
+ | `tokensEstimeContexte` | C-13 | estimated context cost; `CONTEXT_CEILING` bounds each tool and the sum |
97
+
98
+ The sets are closed TypeScript unions: a value outside them does not compile, and a
99
+ list tool cannot omit its mechanism or its pagination. Each tool's record is a plain
100
+ object of literals so a static checker can read it. `list_items` pages
101
+ its rows: `limit` (default 20, capped at 100) and an opaque
102
+ `cursor`; a truncated page carries `nextCursor` and `exhausted: false`, the last page
103
+ `exhausted: true`; an invalid or expired cursor is a tool
104
+ input error: a result with `isError: true` and `structuredContent.error.code`
105
+ `invalid_cursor` (message FR/EN), never a silent first page. The JSON-RPC `-32602`
106
+ stays with the protocol list methods' cursor, which the SDK serves.
107
+
108
+ `tests/toolRecordComplete.test.ts` and the C-8..C-13 suites walk `tools/list` and
109
+ fail on any gap. Annotations are untrusted hints for the client (mcp-standard C-15);
110
+ never gate an action on them alone.
82
111
 
83
112
  ## Authentication tiers
84
113
 
@@ -125,7 +154,15 @@ shared between instances) unless both durable adapters are injected or
125
154
  single-instance deployment. Behind a reverse proxy set `MCP_TRUSTED_PROXY_COUNT`
126
155
  to the number of trusted proxies (default `0`): the `/register` rate limit then
127
156
  keys on the proxy-appended `X-Forwarded-For` entry, never on the client-supplied
128
- first one; with `0` it keys on the socket address and ignores the header.
157
+ first one; with `0` it keys on the socket address and ignores the header. The value is the
158
+ number of proxy HOPS that append to `X-Forwarded-For`, not the number of products you run:
159
+ Railway needs `2` (measured: its header carries the client, then the edge proxy). A count
160
+ that is too LOW fails silently, because the limiter then keys on a proxy address shared by
161
+ many clients. To measure it, send 21 registrations from one client and expect a `429` on
162
+ the 21st. An IPv6 client is keyed on its /64 and ALSO counted against its /48: every /64 inside one
163
+ /48 shares `MCP_REGISTER_LIMIT_PER_48_PER_MINUTE` registrations per minute (default `100`), so
164
+ one /48, which holds 65536 /64s, cannot fill the client registry to its `503`. An invalid value
165
+ stops the server at start.
129
166
 
130
167
  **Request limits and CORS**: An operator variable that is set but invalid (for example `MCP_MAX_BODY_BYTES=abc`) stops the server at startup with the variable's name and the expected form, never a silent default; unset or empty keeps the default. the Node entry reads at most `MCP_MAX_BODY_BYTES`
131
168
  (default 1 MiB) of a request body and answers `413` before any handler runs.
@@ -195,6 +232,209 @@ organisation (`isRowInTenant`); a token with no organisation gets an explicit
195
232
  tool refusal, never an unscoped view. Resolve the caller's tenant only from the
196
233
  verified token, never from a tool argument.
197
234
 
235
+ ### The verified person in a tool
236
+
237
+ A tool can also need the PERSON behind the call (subject, role, admin standing), for example to
238
+ filter rows per person. The person is resolved once per `/mcp` request by
239
+ `@vantageos/cloud-identity`'s `resolveCallerFromClaims`, from the same verified claims as the
240
+ tenant; the server never reads a claim itself. The package reads two stores you inject, and the
241
+ admin roles are yours to name (required, non-empty, no default):
242
+
243
+ ```ts
244
+ import { buildHttpApp } from "@vantageos/mcp-boilerplate";
245
+
246
+ const app = buildHttpApp({
247
+ authMode: "oauth",
248
+ authorizationServer,
249
+ identity: {
250
+ orgLookups: { orgById: (orgId) => orgStore.get(orgId) },
251
+ principalLookups: {
252
+ personById: (personId, orgId) => people.get(orgId, personId),
253
+ organisationById: (orgId) => orgStore.row(orgId),
254
+ orgKindOf: (orgId) => orgStore.kind(orgId),
255
+ },
256
+ adminRoles: ["org:admin"],
257
+ },
258
+ });
259
+ ```
260
+
261
+ A tool declares that it needs the person by wrapping its handler with `withVerifiedPerson`
262
+ (in `src/mcpServerFactory.ts`, where `deps` is the per-request context the tenant travels in):
263
+
264
+ ```ts
265
+ server.registerTool("my_rows", config, withVerifiedPerson(deps, async (args, person) => {
266
+ // person: { subject, actorName, org, principal, role, isOrgAdmin }, frozen, as the package returned it
267
+ return structuredResult({ rows: rowsVisibleTo(person.subject) });
268
+ }));
269
+ ```
270
+
271
+ Unless the person resolved, the handler does not run: the call gets an MCP tool error with the
272
+ stable code `person_unresolved` (`structuredContent.error.code`), a FR/EN message per
273
+ `X-MCP-Locale`, and a `reason`: `identity_not_configured` (no `identity` injected),
274
+ `no_verified_session` (public tier, and **stdio, which never has a person**: there is no bearer
275
+ on that transport) or `refused` (the package refused; its `code` and `reason` are carried, never
276
+ a claim value). A tool that does not wrap its handler runs exactly as before, with no person.
277
+
278
+ ## Calling an upstream API (OAuth 2 client_credentials)
279
+
280
+ A server derived from this boilerplate that reads a third-party API with its own credentials
281
+ uses `createClientCredentialsTokenSource` (`src/upstream/clientCredentials.ts`). Create it ONCE
282
+ at module scope: the MCP server is rebuilt per request, and the token cache lives in the source.
283
+
284
+ ```ts
285
+ const upstream = createClientCredentialsTokenSource(
286
+ clientCredentialsFromEnv({
287
+ clientIdVar: "UPSTREAM_CLIENT_ID",
288
+ clientSecretVar: "UPSTREAM_CLIENT_SECRET",
289
+ tokenUrlVar: "UPSTREAM_TOKEN_URL",
290
+ })
291
+ );
292
+ // in a tool handler:
293
+ const res = await upstream.fetchWithToken("https://api.upstream.example/v1/things");
294
+ ```
295
+
296
+ The variable names are yours to choose. The token is cached until `expires_in` minus a 60 s
297
+ skew, concurrent callers share one token request, and a 401 triggers one token refetch and one
298
+ retry, never more. Client authentication is `client_secret_basic` by default, or
299
+ `client_secret_post`. A missing variable is named at start, its value never shown; a token
300
+ endpoint failure is an `UpstreamTokenError` with the status and OAuth `error` code, never a
301
+ credential or a token.
302
+
303
+ ### Reading every page of an offset-paginated endpoint
304
+
305
+ `createPaginatedUpstreamClient` (`src/upstream/paginatedClient.ts`) reads an upstream endpoint
306
+ paginated by offset and limit. The parameter names, where they travel (`query` or `jsonBody`)
307
+ and the method are configuration. Create it once at module scope, beside the token source:
308
+
309
+ ```ts
310
+ const things = createPaginatedUpstreamClient({
311
+ fetchImpl: upstream.fetchWithToken,
312
+ baseUrl: "https://api.upstream.example/v1/",
313
+ allowedOrigins: ["https://api.upstream.example"],
314
+ offsetParam: "offset",
315
+ limitParam: "limit",
316
+ placement: "jsonBody", // POST with offset and limit in the JSON body; "query" sends a GET
317
+ rowCeiling: 5000,
318
+ extractRows: (body) => (body as { data: unknown[] }).data,
319
+ });
320
+ const all = await things.readAll("things", { status: "active" }); // every page, or a refusal
321
+ const page = await things.readPage("things", { status: "active" }, cursor); // one page per call
322
+ ```
323
+
324
+ `allowedOrigins` and `rowCeiling` are required. A path resolving to another origin is refused before
325
+ any network call, so the bearer token never leaves for a host a tool argument names, and redirects
326
+ are refused rather than followed (`redirect: "error"`). `readAll` stops on the short page (or at the
327
+ total, with `extractTotal`) and refuses a set above the ceiling with `row_ceiling_exceeded`, never a
328
+ silently truncated one. `readPage` returns `{ rows, nextCursor?, exhausted }`; its cursor is bound
329
+ to the path and params, and one replayed against another query or altered without recomputing its
330
+ fingerprint is refused as `invalid_cursor`. The fingerprint is unkeyed: it detects misuse, not
331
+ forgery; the offset carries no authority and a minted offset above the ceiling is refused before any
332
+ request. An empty page before a declared total is refused as `inconsistent_page`, never
333
+ re-requested. 429 and 503 are retried with exponential backoff (default 5 attempts, 500 ms base,
334
+ 30 s cap) and honour `Retry-After`; no other status is retried. Errors (`UpstreamPageError`) name the
335
+ path and the status, never a token, a parameter value or the body. Keep `pageSize` (default 100)
336
+ within the upstream's real maximum: a page silently capped below it reads as the last one unless the
337
+ response declares a total.
338
+
339
+ ## Declaring your own configuration
340
+
341
+ A derived server declares the environment variables it reads, once, and gets a typed, validated,
342
+ frozen object at start (`src/declaredVariables.ts`):
343
+
344
+ ```ts
345
+ import { defineVariables, describeVariables, loadVariables } from "@vantageos/mcp-boilerplate";
346
+
347
+ export const declared = defineVariables({
348
+ apiBaseUrl: {
349
+ env: "UPSTREAM_API_BASE_URL",
350
+ type: "url",
351
+ description: { en: "Upstream API base URL", fr: "URL de base de l'API amont" },
352
+ },
353
+ readKey: {
354
+ env: "LAKE_READ_KEY",
355
+ type: "string",
356
+ secret: true,
357
+ description: { en: "Data lake read key", fr: "Clé de lecture du lac de données" },
358
+ },
359
+ pageSize: {
360
+ env: "UPSTREAM_PAGE_SIZE",
361
+ type: "integer",
362
+ min: 1,
363
+ max: 500,
364
+ required: false,
365
+ default: 100,
366
+ description: { en: "Rows per upstream page", fr: "Lignes par page amont" },
367
+ },
368
+ fieldMap: {
369
+ env: "FIELD_MAPPINGS",
370
+ type: "json",
371
+ description: { en: "Code-to-label mappings", fr: "Correspondances code-libellé" },
372
+ },
373
+ });
374
+
375
+ export const settings = loadVariables(declared); // settings.pageSize: number
376
+ console.log(describeVariables(declared)); // the contract, for --help: no value, no default
377
+ ```
378
+
379
+ The variable names are yours to choose. Types: `string`, `integer` (`min`/`max`), `boolean`
380
+ (`"0"` or `"1"`), `url` (https, http on loopback only), `originList`, `enum` (`values`) and `json`
381
+ (an object or an array). A variable is required unless it says `required: false`; a `default`
382
+ needs `required: false`. Unset or empty is missing; a set but invalid value is refused, never
383
+ replaced by the default. Every missing or invalid variable is reported together in ONE
384
+ `ConfigurationError` (`problems: { key, env, reason, expected }[]`, message in English and
385
+ French) that names the variables and never shows a value. The result is frozen and typed from
386
+ the declaration: an undeclared key does not compile, and reading one at runtime throws.
387
+ `JSON.stringify`, `String()` and `console.log` of the result never throw and show a `secret: true`
388
+ value as `[secret]`; a secret is skipped by spread and `Object.entries`, and read by name only. A
389
+ broken declaration (duplicate env name, unknown type or property, a default that does not validate)
390
+ is refused when `defineVariables` runs. The boilerplate's own variables (`OAUTH_ENV`, the server
391
+ knobs, `clientCredentialsFromEnv`) do not use the registry yet.
392
+
393
+ ## Reading the client's data lake
394
+
395
+ A derived server reads its client's data lake (the single persisted source) through a READ-ONLY
396
+ interface, `LakeReader` (`src/lake/reader.ts`): `listPage({ after?, limit, scope? })` returns
397
+ `{ rows, exhausted }` in ascending key order, `get(key, scope?)` returns one row or `undefined`,
398
+ and `keyOf(row)` names the stable key the cursor carries. There is no write method. `list_items`
399
+ reads through the reader injected as `buildHttpApp({ lake })` (or `McpServerDeps.lake`); with none
400
+ injected it reads the in-memory seed (`EXAMPLE_ITEMS_READER`), exactly as before. The caller's
401
+ tenant travels INTO the read as `scope`, and every row that comes back is re-checked: a row of
402
+ another tenant fails the call closed. `readPage` applies the tool's bounds and cursor to any reader
403
+ and checks each page against the contract; a page it cannot read (not a list, more rows than
404
+ asked, keys out of order or not after the cursor, a truncated page with no row, a row with no key)
405
+ is a `LakeReadError`, which `list_items` answers as a tool error with the stable code
406
+ `lake_unreadable` and the reason, never an empty or partial page.
407
+
408
+ The Convex adapter ships at its own subpath; `convex` is an optional peer dependency, installed
409
+ only by a server that imports it:
410
+
411
+ ```ts
412
+ import { convexLakeReaderFromEnv } from "@vantageos/mcp-boilerplate/lake/convex";
413
+
414
+ const lake = convexLakeReaderFromEnv(
415
+ process.env,
416
+ { urlVar: "LAKE_URL", tokenVar: "LAKE_READ_KEY" },
417
+ {
418
+ functions: { page: "lakeRead:listRows", get: "lakeRead:getRow" },
419
+ keyField: "rowKey",
420
+ tokenArg: "readKey", // omit to send the token through setAuth (Authorization: Bearer)
421
+ }
422
+ );
423
+ const app = buildHttpApp({ authMode: "oauth", authorizationServer, lake });
424
+ ```
425
+
426
+ The variable names, the query function names and the key field are configuration: nothing in the
427
+ package names a table or a function. The connection variables are declared with `defineVariables`
428
+ (the URL as `url`: https, http on loopback only; the token as a `secret`), so a missing or invalid
429
+ one is refused at start, by name, never by value. The adapter only ever calls
430
+ `ConvexHttpClient.query`, never a mutation or an action. The page function receives
431
+ `{ after?, limit, tenantId? }` and returns the rows whose key is after `after`, ascending, at most
432
+ `limit` (the adapter asks for one row more than the page to tell a truncated page from the last
433
+ one); the get function receives `{ key, tenantId? }` and returns the row or `null`. An optional
434
+ `parseRow` (e.g. a Zod `parse`) types each row and refuses one it cannot read. A failing call is
435
+ `read_failed`, and the client's message is not forwarded: no error carries the token, the URL or a
436
+ row value.
437
+
198
438
  ## Server-side i18n (FR/EN)
199
439
 
200
440
  `src/i18n.ts` localizes MCP error/validation messages and the picker page: the
@@ -215,9 +455,10 @@ Hono app in-process with the SDK v2 `Client`; the stdio suite spawns the built
215
455
 
216
456
  ## Adding a tool
217
457
 
218
- Register it once in `src/mcpServerFactory.ts` with `title`, the four
219
- annotations, `inputSchema` and `outputSchema`, and return
220
- `structuredResult(...)`. It is then served on both transports and both
458
+ Declare its record in `src/toolRecord.ts` (authority pair, verb, table, and for a
459
+ list/search the mechanism and pagination), register it once in
460
+ `src/mcpServerFactory.ts` with `title`, `annotations: YOUR_ANNOTATIONS`,
461
+ `inputSchema` and `outputSchema`, and return `structuredResult(...)`. It is then served on both transports and both
221
462
  protocol eras. `tests/toolRecordComplete.test.ts` keeps it honest.
222
463
 
223
464
  ## Packaging & distribution
@@ -254,10 +495,56 @@ npm publish # prepublishOnly re-runs build + test + evals
254
495
  Publication is proven at the registry's `dist-tag`, never by the exit code of
255
496
  `npm publish`.
256
497
 
498
+ The version lives in `package.json` AND in `mcp-server.json` (the server manifest,
499
+ mcp-standard C-18). Bump both to the same value: `scripts/sync-plugin-version.mjs`
500
+ reads the version from the manifest and refuses a `package.json` that disagrees,
501
+ naming both. The manifest also declares the server's capabilities, which the factory
502
+ serves, and the feature switches later releases read.
503
+
504
+ ## Pin and verify the published package
505
+
506
+ Install from npm, the registry of record, and pin an EXACT version so an update never
507
+ arrives unasked. `1.2.3` below is an example, not a published version: list the real
508
+ ones and replace it with the version you audited.
509
+
510
+ ```bash
511
+ npm view @vantageos/mcp-boilerplate versions --json # the published versions
512
+ npm install @vantageos/mcp-boilerplate@1.2.3 # pin an exact version
513
+ ```
514
+
515
+ Roll back by pinning the previous version; no published version is ever removed.
516
+
517
+ ```bash
518
+ npm install @vantageos/mcp-boilerplate@1.2.2 # rollback: the previous version
519
+ ```
520
+
521
+ A second, independent channel carries the same tag: the GitHub release of
522
+ `elpiarthera/vantage-mcp-boilerplate`. The package is published with
523
+ `npm publish --provenance` by `.github/workflows/release.yml`; check the attestation
524
+ with `npm audit signatures`.
525
+
526
+ GitHub Actions is disabled across the ElPi Corp fleet, so a publication made without CI
527
+ has no provenance attestation. The equivalent check is the checksum of the packed
528
+ tarball, reproduced from a clean checkout of the tag and read back from the registry:
529
+
530
+ ```bash
531
+ npm pack # prints the tarball name
532
+ shasum mcp-boilerplate-1.2.3.tgz # the checksum you reproduce (use the real name)
533
+ curl -s -H 'Cache-Control: no-cache' https://registry.npmjs.org/@vantageos%2Fmcp-boilerplate/1.2.3 \
534
+ | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).dist.shasum"
535
+ ```
536
+
537
+ Equal sums: the registry serves what the source builds. A running server proves what
538
+ it serves the same way: `GET /health` returns `{ status, version, buildId }` before any
539
+ authentication. `buildId` is a hash of the files the process runs from, so it changes
540
+ when they change, which `version` does not guarantee.
541
+
257
542
  ## MCP Apps UI layer
258
543
 
259
- `render_items_ui` renders `EXAMPLE_ITEMS` as an interactive widget
260
- (`io.modelcontextprotocol/ui`, spec 2026-01-26).
544
+ `list_items` renders its page of `EXAMPLE_ITEMS` as an interactive widget
545
+ (`io.modelcontextprotocol/ui`, spec 2026-01-26). One operation on one table is one
546
+ tool (mcp-standard C-12), so the data tool itself carries the UI link; there is no
547
+ separate render tool.
261
548
 
262
549
  - The tool links its `ui://` resource through the nested `_meta.ui.resourceUri`
263
550
  with explicit `visibility: ["model", "app"]`. The tool is registered with
@@ -281,7 +568,7 @@ one self-contained HTML file (an in-repo inlining plugin in `vite.config.ts`,
281
568
  `dist/ui-bundle/widget.html`) by `npm run build:ui`, on a path that never
282
569
  collides with tsc's `dist/ui/`.
283
570
 
284
- **Identity layer**: `@vantageos/cloud-identity` (^0.19.0) is the only identity
571
+ **Identity layer**: `@vantageos/cloud-identity` (^0.20.2) is the only identity
285
572
  package. Its resource-server entry point (`verifyMcpAccessToken`,
286
573
  `buildProtectedResourceMetadata`) verifies the Bearer token on `/mcp`; `jose` is
287
574
  kept only to mint the authorization server's access tokens. An unreachable key set
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Build identity DERIVED from the served tree (mcp-standard C-25, S-7).
3
+ *
4
+ * `version` is a declared value: it changes only when someone remembers to change it, so its
5
+ * equality between source and server proves nothing about the code actually running. A build
6
+ * identity is computed from the files the process was started from, so it changes when they
7
+ * change. It is exposed beside `version`, before authentication, on `/health` (httpApp.ts) and
8
+ * in the server identity of `server/discover` / `initialize` (mcpServerFactory.ts), so a
9
+ * deployment is proven by what the server RETURNS, never by what the deploy command printed.
10
+ *
11
+ * The identity is the first 16 hex characters of a SHA-256 over every file under the directory
12
+ * this module runs from (`dist/` when built, `src/` under the test runner): relative path and
13
+ * content, in path order, so mtimes and directory order never matter and a rename counts.
14
+ * It reveals nothing about the infrastructure (S-7 anti-rule: the version and nothing else).
15
+ */
16
+ /** The identity of the tree rooted at `dir`. Refuses a tree with no file: nothing was built. */
17
+ export declare function computeBuildIdentity(dir: string): string;
18
+ /** The identity of the tree THIS process runs from, computed once. */
19
+ export declare function getBuildIdentity(): string;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Build identity DERIVED from the served tree (mcp-standard C-25, S-7).
3
+ *
4
+ * `version` is a declared value: it changes only when someone remembers to change it, so its
5
+ * equality between source and server proves nothing about the code actually running. A build
6
+ * identity is computed from the files the process was started from, so it changes when they
7
+ * change. It is exposed beside `version`, before authentication, on `/health` (httpApp.ts) and
8
+ * in the server identity of `server/discover` / `initialize` (mcpServerFactory.ts), so a
9
+ * deployment is proven by what the server RETURNS, never by what the deploy command printed.
10
+ *
11
+ * The identity is the first 16 hex characters of a SHA-256 over every file under the directory
12
+ * this module runs from (`dist/` when built, `src/` under the test runner): relative path and
13
+ * content, in path order, so mtimes and directory order never matter and a rename counts.
14
+ * It reveals nothing about the infrastructure (S-7 anti-rule: the version and nothing else).
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { readFileSync, readdirSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { tBoth } from "./i18n.js";
21
+ /** Every file under `dir` as a posix-relative path, sorted. Throws, naming `dir`, if unreadable. */
22
+ function listFiles(dir, prefix = "") {
23
+ let entries;
24
+ try {
25
+ entries = readdirSync(join(dir, prefix), { withFileTypes: true });
26
+ }
27
+ catch (error) {
28
+ throw new Error(tBoth("buildIdentity.unreadable", {
29
+ dir,
30
+ reason: error instanceof Error ? error.message : String(error),
31
+ }));
32
+ }
33
+ const files = [];
34
+ for (const entry of entries) {
35
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
36
+ if (entry.isDirectory())
37
+ files.push(...listFiles(dir, rel));
38
+ else if (entry.isFile())
39
+ files.push(rel);
40
+ }
41
+ return files.sort();
42
+ }
43
+ /** The identity of the tree rooted at `dir`. Refuses a tree with no file: nothing was built. */
44
+ export function computeBuildIdentity(dir) {
45
+ const files = listFiles(dir);
46
+ if (files.length === 0)
47
+ throw new Error(tBoth("buildIdentity.empty", { dir }));
48
+ const hash = createHash("sha256");
49
+ for (const rel of files) {
50
+ const content = createHash("sha256")
51
+ .update(readFileSync(join(dir, rel)))
52
+ .digest("hex");
53
+ hash.update(`${rel}\0${content}\n`);
54
+ }
55
+ return hash.digest("hex").slice(0, 16);
56
+ }
57
+ let processIdentity;
58
+ /** The identity of the tree THIS process runs from, computed once. */
59
+ export function getBuildIdentity() {
60
+ processIdentity ??= computeBuildIdentity(dirname(fileURLToPath(import.meta.url)));
61
+ return processIdentity;
62
+ }