@vantageos/mcp-boilerplate 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +161 -0
- package/README.fr.md +323 -15
- package/README.md +306 -15
- package/dist/buildIdentity.d.ts +19 -0
- package/dist/buildIdentity.js +62 -0
- package/dist/declaredVariables.d.ts +205 -0
- package/dist/declaredVariables.js +451 -0
- package/dist/exampleItems.d.ts +15 -0
- package/dist/exampleItems.js +28 -1
- package/dist/httpApp.d.ts +17 -0
- package/dist/httpApp.js +19 -2
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/lake/convex.d.ts +101 -0
- package/dist/lake/convex.js +224 -0
- package/dist/lake/reader.d.ts +88 -0
- package/dist/lake/reader.js +120 -0
- package/dist/locales/en.json +42 -0
- package/dist/locales/fr.json +42 -0
- package/dist/mcpServerFactory.d.ts +31 -14
- package/dist/mcpServerFactory.js +116 -48
- package/dist/oauth/authorizationServer.js +64 -18
- package/dist/oauth/clerk.d.ts +2 -0
- package/dist/oauth/clerk.js +15 -5
- package/dist/oauth/clientRegistry.d.ts +19 -4
- package/dist/oauth/clientRegistry.js +20 -10
- package/dist/oauth/picker.d.ts +7 -2
- package/dist/oauth/picker.js +90 -13
- package/dist/oauth/registerLimiter.d.ts +34 -0
- package/dist/oauth/registerLimiter.js +95 -0
- package/dist/oauth/settings.d.ts +4 -0
- package/dist/oauth/settings.js +4 -0
- package/dist/pagination.d.ts +66 -0
- package/dist/pagination.js +100 -0
- package/dist/serverManifest.d.ts +32 -0
- package/dist/serverManifest.js +68 -0
- package/dist/stdio.js +5 -1
- package/dist/tenancy.d.ts +1 -1
- package/dist/toolRecord.d.ts +94 -0
- package/dist/toolRecord.js +52 -0
- package/dist/ui/uiResource.d.ts +11 -4
- package/dist/ui/uiResource.js +8 -4
- package/dist/upstream/clientCredentials.d.ts +106 -0
- package/dist/upstream/clientCredentials.js +283 -0
- package/dist/upstream/paginatedClient.d.ts +142 -0
- package/dist/upstream/paginatedClient.js +422 -0
- package/dist/verifiedPerson.d.ts +85 -0
- package/dist/verifiedPerson.js +112 -0
- package/docs/oauth-stores.md +2 -1
- package/examples/oauth.env.example +7 -0
- package/mcp-server.json +13 -0
- package/package.json +19 -4
- package/plugin.json +1 -1
- package/skills/connect-mcp-server/SKILL.md +5 -5
package/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
|
|
13
|
-
|
|
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`,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
@@ -183,6 +220,10 @@ server you host, in this order:
|
|
|
183
220
|
logs one warning at boot, in English and French, naming the Home URL requirement. It is a
|
|
184
221
|
warning, never a refusal: it means "a remote sign-in will only work if the Home URL points
|
|
185
222
|
at this server, or if you move to a production instance". It carries no key or secret.
|
|
223
|
+
6. **The consent page.** The organisation picker uses the VantagePeers dashboard design: dark
|
|
224
|
+
by default, light when the person's system asks for it, one card per organisation with its
|
|
225
|
+
role. It loads no external asset, so it carries its own tokens. Set `AUTHORIZE_BRAND_NAME`
|
|
226
|
+
to show your product's name above the title; nothing is shown by default.
|
|
186
227
|
|
|
187
228
|
### Multi-tenancy
|
|
188
229
|
|
|
@@ -191,6 +232,209 @@ organisation (`isRowInTenant`); a token with no organisation gets an explicit
|
|
|
191
232
|
tool refusal, never an unscoped view. Resolve the caller's tenant only from the
|
|
192
233
|
verified token, never from a tool argument.
|
|
193
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
|
+
|
|
194
438
|
## Server-side i18n (FR/EN)
|
|
195
439
|
|
|
196
440
|
`src/i18n.ts` localizes MCP error/validation messages and the picker page: the
|
|
@@ -211,9 +455,10 @@ Hono app in-process with the SDK v2 `Client`; the stdio suite spawns the built
|
|
|
211
455
|
|
|
212
456
|
## Adding a tool
|
|
213
457
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
`
|
|
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
|
|
217
462
|
protocol eras. `tests/toolRecordComplete.test.ts` keeps it honest.
|
|
218
463
|
|
|
219
464
|
## Packaging & distribution
|
|
@@ -250,10 +495,56 @@ npm publish # prepublishOnly re-runs build + test + evals
|
|
|
250
495
|
Publication is proven at the registry's `dist-tag`, never by the exit code of
|
|
251
496
|
`npm publish`.
|
|
252
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
|
+
|
|
253
542
|
## MCP Apps UI layer
|
|
254
543
|
|
|
255
|
-
`
|
|
256
|
-
(`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.
|
|
257
548
|
|
|
258
549
|
- The tool links its `ui://` resource through the nested `_meta.ui.resourceUri`
|
|
259
550
|
with explicit `visibility: ["model", "app"]`. The tool is registered with
|
|
@@ -277,7 +568,7 @@ one self-contained HTML file (an in-repo inlining plugin in `vite.config.ts`,
|
|
|
277
568
|
`dist/ui-bundle/widget.html`) by `npm run build:ui`, on a path that never
|
|
278
569
|
collides with tsc's `dist/ui/`.
|
|
279
570
|
|
|
280
|
-
**Identity layer**: `@vantageos/cloud-identity` (^0.
|
|
571
|
+
**Identity layer**: `@vantageos/cloud-identity` (^0.20.2) is the only identity
|
|
281
572
|
package. Its resource-server entry point (`verifyMcpAccessToken`,
|
|
282
573
|
`buildProtectedResourceMetadata`) verifies the Bearer token on `/mcp`; `jose` is
|
|
283
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
|
+
}
|