@vantageos/mcp-boilerplate
v0.5.0
Published
Clonable MCP server skeleton on the official @modelcontextprotocol/server SDK v2 (protocol 2026-07-28 + 2025 legacy, stdio + stateless Streamable HTTP), with its own OAuth 2.1 authorization server (Clerk person + organisation, cloud-identity), MCP Apps UI
Maintainers
Readme
@vantageos/mcp-boilerplate
SELLABLE AS: mcp-boilerplate — a clonable MCP server starter for the Claude Code marketplace / VantageRegistry catalogue and for ChatGPT, published as @vantageos/mcp-boilerplate on npm.
A clonable MCP server skeleton on the official
@modelcontextprotocol/server
SDK v2: protocol 2026-07-28 with the 2025 handshake still served, stdio and
stateless Streamable HTTP from one server definition, its own OAuth 2.1
authorization server (a Clerk person picks an organisation), an MCP Apps UI
and FR/EN messages. No custom REST route ever stands in front of a tool call.
It ships two small example tools (ping, list_items) that exercise every
layer; list_items also carries the MCP Apps widget. Replace them with yours; the wiring stays.
Structure
src/
mcpServerFactory.ts # single MCP server definition, shared by both transports
version.ts # server name + version, derived from package.json
serverManifest.ts # fail-closed loader of mcp-server.json (the server manifest)
toolRecord.ts # typed tool record: authority, verb, table, mechanism, pagination, cost
pagination.ts # cursor pagination applied by the tool (default, finite max, invalid_cursor)
signedCursor.ts # HMAC-signed cursor primitive (createCursorSigner), used by every cursor
buildIdentity.ts # build identity derived from the served tree
stdio.ts # serveStdio(factory): both protocol eras over stdio
http.ts # Node http.Server adapter around the Hono app
httpApp.ts # createMcpHandler(factory) on /mcp + the OAuth gate
auth.ts # resource-server wiring (@vantageos/cloud-identity)
config.ts # env-derived AuthMode + external-issuer AuthConfig
tenancy.ts # verified JWT -> tenant; rows filtered by stored orgId
lake/ # read-only data-lake reader: interface + in-memory default, Convex adapter
reader.ts convex.ts
oauth/ # own authorization server (cloud-identity decides)
authorizationServer.ts settings.ts clerk.ts tokenIssuer.ts
clientRegistry.ts codeStore.ts picker.ts
ui/ # MCP Apps widget + ui:// resource
mcp-server.json # the server manifest: capabilities, transport, version, feature switches
.github/workflows/release.yml # npm publish --provenance + registry proof (Actions is disabled fleet-wide)
skills/connect-mcp-server/SKILL.md # onboarding skill (English)
scripts/
plugin-manifest.source.json # the ONE source of every packaging manifest
sync-plugin-version.mjs # generates the four manifests (runs in `npm run build`)
generate-signing-key.mjs # prints an RS256 private JWK for MCP_OAUTH_SIGNING_JWK
tests/ evals/ docs/Quick start
npm install
npm run build && npm run build:ui
# stdio (local client config, e.g. Claude Code / Cursor) — never gated,
# the local process invoking stdio IS the caller.
npm run start:stdio
# Streamable HTTP, public tier (local/self-host, trusted caller):
AUTH_MODE=public PORT=8787 npm run start:http
# Streamable HTTP, OAuth tier (default): this server is its own authorization
# server. See examples/oauth.env.example for every required variable.
node scripts/generate-signing-key.mjs # -> MCP_OAUTH_SIGNING_JWK
PORT=8787 npm run start:httpProtocol and transports
One factory (createBoilerplateMcpServer) builds a fresh McpServer per
request (HTTP) or per connection (stdio). The SDK owns the era decision:
- a 2026-07-28 client opens with
server/discover;tools/list,server/discoverandresources/*results carryresultType,ttlMsandcacheScopefrom the SDK'scacheHints(set once in the factory); - a 2025-era client opens with
initializeand is served through the SDK's stateless legacy leg on HTTP, or a pinned connection on stdio.
The 2025 floor is 2025-06-18: the SDK's supportedProtocolVersions option
lists only 2025-11-25 and 2025-06-18, so an older initialize is
counter-offered 2025-11-25, never served at its own version. The advertised
server version is read from package.json.
Tool records
Every tool declares title, a typed input schema and a typed output schema (Zod 4),
and returns structuredContent plus a text block with the same JSON. Its
annotations carry the typed record of src/toolRecord.ts, readable from
tools/list without reading the source:
| Key | Rule | Meaning |
|---|---|---|
| readOnlyHint, destructiveHint, idempotentHint, openWorldHint | C-15 | the four behavioural booleans |
| 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 |
| verb, table | C-9, C-12 | the operation (list, get, create, pure...) and the data it touches |
| searchMechanism | C-10 | how a list/search is served (indexed-range here); unindexed-scan does not publish. A tool answered by a live third-party call declares upstream-delegated(<upstream>) with openWorldHint: true; assertToolRecords refuses an undeclared live call, or the declaration without the hint |
| paginationCursor, paginationDefault, paginationMax | C-11 | cursor, default page size and a finite maximum, applied by src/pagination.ts |
| tokensEstimeContexte | C-13 | estimated context cost; the server's own contextCeiling (global, perTool, in mcp-server.json) bounds each tool and the sum, enforced by assertToolRecords at start; a missing or non-numeric ceiling stops the server |
The sets are closed TypeScript unions: a value outside them does not compile, and a
list tool cannot omit its mechanism or its pagination. Each tool's record is a plain
object of literals so a static checker can read it. list_items pages
its rows: limit (default 20, capped at 100) and an opaque, signed
cursor; a truncated page carries nextCursor and exhausted: false, the last page
exhausted: true; an invalid or expired cursor is a tool
input error: a result with isError: true and structuredContent.error.code
invalid_cursor (message FR/EN), never a silent first page. The JSON-RPC -32602
stays with the protocol list methods' cursor, which the SDK serves.
tests/toolRecordComplete.test.ts and the C-8..C-13 suites walk tools/list and
fail on any gap. Annotations are untrusted hints for the client (mcp-standard C-15);
never gate an action on them alone.
Authentication tiers
| AUTH_MODE | Behavior |
|---|---|
| oauth (default) | OAuth 2.1. Without MCP_OAUTH_ISSUER, this server is both the authorization server and the resource server (below). With MCP_OAUTH_ISSUER + MCP_OAUTH_AUDIENCE, it is a resource server only, for an external issuer. Missing or invalid token -> 401 + WWW-Authenticate pointing at the PRM. |
| public | No gate on HTTP — local/self-host, trusted caller. No authorization-server document is served. |
stdio is never gated in either mode.
Own authorization server (ruling R1)
| Route | Role |
|---|---|
| GET /.well-known/oauth-authorization-server (+ openid-configuration) | RFC 8414 metadata: S256 only, RFC 9207 iss, CIMD and DCR advertised |
| GET /.well-known/oauth-protected-resource/mcp | RFC 9728 PRM (resource = <base>/mcp) via cloud-identity |
| GET /.well-known/jwks.json | public signing keys only |
| POST /register | Dynamic Client Registration fallback (public clients, PKCE) |
| GET /authorize, GET /authorize/callback, POST /authorize/org | Clerk sign-in, then the person + organisation picker with consent |
| POST /token | authorization_code + PKCE S256 -> RS256 JWT bound to the resource, the issuer and the chosen organisation |
| GET /userinfo | OIDC subject (and email when the scope allows) |
Every decision — redirect/PKCE/resource/scope checks, the consent token, code
binding and redemption, discovery and userinfo shapes — is made by
@vantageos/cloud-identity (startAuthorize, resumeAuthorize,
exchangeAuthorizationCode, buildDiscoveryDocument, buildUserInfo). This app
supplies the environment, the stores, the Clerk calls, the picker page and the
signature. A client may identify itself by a Client ID Metadata Document URL
(fetched SSRF-guarded) or register through /register.
Fail-closed: a missing variable (PUBLIC_BASE_URL, CLERK_DOMAIN,
AUTHORIZE_SIGN_IN_URL, AUTHORIZE_CALLBACK_URL, AUTHORIZE_STATE_SECRET,
CLERK_SECRET_KEY, MCP_OAUTH_SIGNING_JWK) answers 503 and is named in the
log (names, never values). There is no auto-approve path.
Stores: the authorization-code store and the client registry are interfaces
with in-memory adapters for tests and single-process development. Use durable
adapters in production — contract in docs/oauth-stores.md.
Production guard: with NODE_ENV=production the server refuses to start on
the in-memory stores (codes and registered clients are lost on restart and not
shared between instances) unless both durable adapters are injected or
MCP_ALLOW_IN_MEMORY_STORES=1 is set — an explicit, logged opt-in for a
single-instance deployment. Behind a reverse proxy set MCP_TRUSTED_PROXY_COUNT
to the number of trusted proxies (default 0): the /register rate limit then
keys on the proxy-appended X-Forwarded-For entry, never on the client-supplied
first one; with 0 it keys on the socket address and ignores the header. The value is the
number of proxy HOPS that append to X-Forwarded-For, not the number of products you run:
Railway needs 2 (measured: its header carries the client, then the edge proxy). A count
that is too LOW fails silently, because the limiter then keys on a proxy address shared by
many clients. To measure it, send 21 registrations from one client and expect a 429 on
the 21st. An IPv6 client is keyed on its /64 and ALSO counted against its /48: every /64 inside one
/48 shares MCP_REGISTER_LIMIT_PER_48_PER_MINUTE registrations per minute (default 100), so
one /48, which holds 65536 /64s, cannot fill the client registry to its 503. An invalid value
stops the server at start.
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
(default 1 MiB) of a request body and answers 413 before any handler runs.
/register sends no CORS headers unless the browser origin is listed in
MCP_REGISTER_CORS_ORIGINS (comma-separated). /mcp requires the mcp:full
scope on the own-authorization-server tier (403 insufficient_scope otherwise).
The consent page shows the origin the code is sent to and the requested scope,
flags a Dynamic-Client-Registration client as unverified, and a denial redirects
to the client with error=access_denied (plus state and iss). http: issuer,
sign-in and callback URLs are refused outside loopback.
Origin validation on /mcp (S-22): a request to /mcp that carries an Origin header
(a browser) is refused 403 {"code":"origin_not_allowed","message_en":...,"message_fr":...},
before the auth gate and without echoing the origin, unless the origin is listed in
MCP_ALLOWED_ORIGINS (comma-separated scheme://host[:port], exact and case-insensitive,
declared in src/settings.ts). The default is empty: every browser origin is refused. null,
an empty value and near-misses such as https://claude.ai.evil.example are refused. A request
with no Origin header (server-to-server, Claude Code) passes as before. A wildcard or an entry
with a path stops the server at start with a ConfigurationError naming the variable. The
OAuth and discovery routes (/.well-known/*, /authorize, /token, /register) and /health
are not covered: they are browser navigations or CORS calls of the sign-in flow.
Outbound request timeout (S-13): every request the server emits to an upstream (paginated
API pages, API calls through the client-credentials token source, the external-issuer JWKS, the
Convex lake reader) carries a timeout, UPSTREAM_TIMEOUT_MS (integer milliseconds, default
30000, range 1000 to 300000, declared in src/settings.ts). A request that exceeds it is
aborted and surfaces as the typed error the call site already uses (UpstreamPageError
unreachable naming TimeoutError, LakeReadError read_failed, a JWKS fetch failure the verifier
answers 503); a progressing call never lifts the ceiling. A value that is not an integer or is out of
range stops the server at start with a ConfigurationError naming the variable. The shared mechanism
is src/upstream/timeout.ts, which lists every outbound call site.
Not included: refresh tokens (access tokens last one hour), per-organisation API keys for non-ChatGPT clients (this code never had that path; it is a gap, not a regression), wildcard redirect URIs, and a token revocation list.
Sign-in with ChatGPT and Claude
ChatGPT and Claude send the person to this server's /authorize, which sends them to
Clerk's hosted sign-in and takes them back to /authorize/callback. To make that work on a
server you host, in this order:
- Use a Clerk PRODUCTION instance on the server's own registrable domain. A server at
mcp.example.comtakes the Clerk domainexample.com, so Clerk servesclerk.example.comandaccounts.example.com. Any other domain is a satellite setup and is not measured. Add the DNS CNAMEs the Clerk dashboard lists. Create your own Google OAuth client with the authorized redirect URIhttps://clerk.<domain>/v1/oauth_callback, put its id and secret in the Clerk dashboard, and publish the Google consent screen with an application home page and a privacy policy (an unpublished consent screen only lets test users in). - Set the three URLs.
PUBLIC_BASE_URL=https://<server>;AUTHORIZE_CALLBACK_URL=https://<server>/authorize/callback;AUTHORIZE_SIGN_IN_URL= the instance's hosted sign-in page:https://accounts.<domain>/sign-inon production,https://<instance>.accounts.dev/sign-inon development (the Account Portal URL, shown in the Clerk dashboard under Account Portal). There is no "allowed redirect URL" to fill in the dashboard for this flow: the hosted page sends the browser back only to a host under the instance's own domain (production, per Clerk's documentation, not yet measured) or tolocalhost/ the Home URL (development, measured). This is why step 1 puts the server and the instance on one domain. Measured: the Backend APIredirect_urlslist is not consulted by the hosted pages. - A development instance is for local or test use only. Its hosted sign-in page redirects
only to
localhostor to the instance Home URL. For a remote test, set the development instance's development origin / Home URL to the server origin in the Clerk dashboard, run the test, and revert it afterwards. - How the session reaches the server. After sign-in the browser comes back to
/authorize/callbackwithout a session on your host. The server resolves it with Clerk's own handshake (@vantageos/cloud-identity0.19.0, built on@clerk/backendauthenticateRequest()): it redirects the browser through Clerk's handshake endpoint and Clerk sets__sessionon your host. It needsCLERK_SECRET_KEYand the publishable key:CLERK_PUBLISHABLE_KEY, or, when unset, derived fromCLERK_DOMAINaspk_test_/pk_live_(following the secret key) + base64(<frontend API host>$). Measured: a development instance with a remote server works once its Home URL points at the server. Not yet measured: a production instance on a registrable domain different from the server's. - Boot warning. When the configured instance is a development one (an
sk_test_key, or aCLERK_DOMAINunderaccounts.dev) andPUBLIC_BASE_URLis not localhost, the server logs one warning at boot, in English and French, naming the Home URL requirement. It is a warning, never a refusal: it means "a remote sign-in will only work if the Home URL points at this server, or if you move to a production instance". It carries no key or secret. - The consent page. The organisation picker uses the VantagePeers dashboard design: dark
by default, light when the person's system asks for it, one card per organisation with its
role. It loads no external asset, so it carries its own tokens. Set
AUTHORIZE_BRAND_NAMEto show your product's name above the title; nothing is shown by default.
Multi-tenancy
list_items filters rows by their stored orgId against the caller's verified
organisation (isRowInTenant); a token with no organisation gets an explicit
tool refusal, never an unscoped view. Resolve the caller's tenant only from the
verified token, never from a tool argument.
The verified person in a tool
A tool can also need the PERSON behind the call (subject, role, admin standing), for example to
filter rows per person. The person is resolved once per /mcp request by
@vantageos/cloud-identity's resolveCallerFromClaims, from the same verified claims as the
tenant; the server never reads a claim itself. The package reads two stores you inject, and the
admin roles are yours to name (required, non-empty, no default):
import { buildHttpApp } from "@vantageos/mcp-boilerplate";
const app = buildHttpApp({
authMode: "oauth",
authorizationServer,
identity: {
orgLookups: { orgById: (orgId) => orgStore.get(orgId) },
principalLookups: {
personById: (personId, orgId) => people.get(orgId, personId),
organisationById: (orgId) => orgStore.row(orgId),
orgKindOf: (orgId) => orgStore.kind(orgId),
},
adminRoles: ["org:admin"],
},
});A tool declares that it needs the person by wrapping its handler with withVerifiedPerson
(in src/mcpServerFactory.ts, where deps is the per-request context the tenant travels in):
server.registerTool("my_rows", config, withVerifiedPerson(deps, async (args, person) => {
// person: { subject, actorName, org, principal, role, isOrgAdmin }, frozen, as the package returned it
return structuredResult({ rows: rowsVisibleTo(person.subject) });
}));Unless the person resolved, the handler does not run: the call gets an MCP tool error with the
stable code person_unresolved (structuredContent.error.code), a FR/EN message per
X-MCP-Locale, and a reason: identity_not_configured (no identity injected),
no_verified_session (public tier, and stdio, which never has a person: there is no bearer
on that transport) or refused (the package refused; its code and reason are carried, never
a claim value). A tool that does not wrap its handler runs exactly as before, with no person.
Calling an upstream API (OAuth 2 client_credentials)
A server derived from this boilerplate that reads a third-party API with its own credentials
uses createClientCredentialsTokenSource (src/upstream/clientCredentials.ts). Create it ONCE
at module scope: the MCP server is rebuilt per request, and the token cache lives in the source.
const upstream = createClientCredentialsTokenSource(
clientCredentialsFromEnv({
clientIdVar: "UPSTREAM_CLIENT_ID",
clientSecretVar: "UPSTREAM_CLIENT_SECRET",
tokenUrlVar: "UPSTREAM_TOKEN_URL",
})
);
// in a tool handler:
const res = await upstream.fetchWithToken("https://api.upstream.example/v1/things");The variable names are yours to choose. The token is cached until expires_in minus a 60 s
skew, concurrent callers share one token request, and a 401 triggers one token refetch and one
retry, never more. Client authentication is client_secret_basic by default, or
client_secret_post. A missing variable is named at start, its value never shown; a token
endpoint failure is an UpstreamTokenError with the status and OAuth error code, never a
credential or a token.
Reading every page of an offset-paginated endpoint
createPaginatedUpstreamClient (src/upstream/paginatedClient.ts) reads an upstream endpoint
paginated by offset and limit. The parameter names, where they travel (query or jsonBody)
and the method are configuration. Create it once at module scope, beside the token source:
const things = createPaginatedUpstreamClient({
fetchImpl: upstream.fetchWithToken,
baseUrl: "https://api.upstream.example/v1/",
allowedOrigins: ["https://api.upstream.example"],
offsetParam: "offset",
limitParam: "limit",
placement: "jsonBody", // POST with offset and limit in the JSON body; "query" sends a GET
rowCeiling: 5000,
extractRows: (body) => (body as { data: unknown[] }).data,
});
const all = await things.readAll("things", { status: "active" }); // every page, or a refusal
const page = await things.readPage("things", { status: "active" }, cursor); // one page per callallowedOrigins and rowCeiling are required. A path resolving to another origin is refused before
any network call, so the bearer token never leaves for a host a tool argument names, and redirects
are refused rather than followed (redirect: "error"). readAll stops on the short page (or at the
total, with extractTotal) and refuses a set above the ceiling with row_ceiling_exceeded, never a
silently truncated one. readPage returns { rows, nextCursor?, exhausted }; its cursor is signed
(see Signed cursors) and bound to the path and params: one replayed against
another query, edited, or signed under another key is refused as invalid_cursor before any
request. An empty page before a declared total is refused as inconsistent_page, never
re-requested. 429 and 503 are retried with exponential backoff (default 5 attempts, 500 ms base,
30 s cap) and honour Retry-After; no other status is retried. Errors (UpstreamPageError) name the
path and the status, never a token, a parameter value or the body. Keep pageSize (default 100)
within the upstream's real maximum: a page silently capped below it reads as the last one unless the
response declares a total.
Signed cursors
Every cursor the boilerplate issues (the list_items cursor and the readPage cursor) is a token
<base64url payload>.<base64url tag>, the tag being HMAC-SHA256 of the payload under a server key
(src/signedCursor.ts). A caller can read a cursor but cannot edit or mint one: an edited payload,
a tag made under another key, or a malformed token (empty, no separator, truncated, not base64url)
is refused as invalid_cursor before any read, with an operator-facing reason on the
InvalidCursorError. Verification compares tags in constant time; there is no unsigned fallback.
A derived server that issues its own cursors uses the same primitive:
import { createCursorSigner } from "@vantageos/mcp-boilerplate";
const signer = createCursorSigner(); // 32 random bytes, drawn once
const cursor = signer.sign(JSON.stringify({ v: 1, after: "item-20" }));
const payload = signer.verify(cursor); // or throws InvalidCursorErrorLimit: the default key lives in the process. A restart invalidates every outstanding cursor
(the caller gets invalid_cursor and starts again without one), and instances behind a load
balancer reject each other's cursors. A multi-instance deployment injects one shared key of at
least 32 bytes (a shorter one throws) into every instance: createCursorSigner({ key }), passed as
cursorSigner to buildHttpApp / createBoilerplateMcpServer (the list_items cursor) and to
createPaginatedUpstreamClient (the upstream cursor).
Declaring your own configuration
A derived server declares the environment variables it reads, once, and gets a typed, validated,
frozen object at start (src/declaredVariables.ts):
import { defineVariables, describeVariables, loadVariables } from "@vantageos/mcp-boilerplate";
export const declared = defineVariables({
apiBaseUrl: {
env: "UPSTREAM_API_BASE_URL",
type: "url",
description: { en: "Upstream API base URL", fr: "URL de base de l'API amont" },
},
readKey: {
env: "LAKE_READ_KEY",
type: "string",
secret: true,
description: { en: "Data lake read key", fr: "Clé de lecture du lac de données" },
},
pageSize: {
env: "UPSTREAM_PAGE_SIZE",
type: "integer",
min: 1,
max: 500,
required: false,
default: 100,
description: { en: "Rows per upstream page", fr: "Lignes par page amont" },
},
fieldMap: {
env: "FIELD_MAPPINGS",
type: "json",
description: { en: "Code-to-label mappings", fr: "Correspondances code-libellé" },
},
});
export const settings = loadVariables(declared); // settings.pageSize: number
console.log(describeVariables(declared)); // the contract, for --help: no value, no defaultThe variable names are yours to choose. Types: string, integer (min/max), boolean
("0" or "1"), url (https, http on loopback only), originList, enum (values) and json
(an object or an array). A variable is required unless it says required: false; a default
needs required: false. Unset or empty is missing; a set but invalid value is refused, never
replaced by the default. Every missing or invalid variable is reported together in ONE
ConfigurationError (problems: { key, env, reason, expected }[], message in English and
French) that names the variables and never shows a value. The result is frozen and typed from
the declaration: an undeclared key does not compile, and reading one at runtime throws.
JSON.stringify, String() and console.log of the result never throw and show a secret: true
value as [secret]; a secret is skipped by spread and Object.entries, and read by name only. A
broken declaration (duplicate env name, unknown type or property, a default that does not validate)
is refused when defineVariables runs. The boilerplate's own variables (OAUTH_ENV, the server
knobs, clientCredentialsFromEnv) do not use the registry yet.
Reading the client's data lake
A derived server reads its client's data lake (the single persisted source) through a READ-ONLY
interface, LakeReader (src/lake/reader.ts): listPage({ after?, limit, scope? }) returns
{ rows, exhausted } in ascending key order, get(key, scope?) returns one row or undefined,
and keyOf(row) names the stable key the cursor carries. There is no write method. list_items
reads through the reader injected as buildHttpApp({ lake }) (or McpServerDeps.lake); with none
injected it reads the in-memory seed (EXAMPLE_ITEMS_READER), exactly as before. The caller's
tenant travels INTO the read as scope, and every row that comes back is re-checked: a row of
another tenant fails the call closed. readPage applies the tool's bounds and cursor to any reader
and checks each page against the contract; a page it cannot read (not a list, more rows than
asked, keys out of order or not after the cursor, a truncated page with no row, a row with no key)
is a LakeReadError, which list_items answers as a tool error with the stable code
lake_unreadable and the reason, never an empty or partial page.
The Convex adapter ships at its own subpath; convex is an optional peer dependency, installed
only by a server that imports it:
import { convexLakeReaderFromEnv } from "@vantageos/mcp-boilerplate/lake/convex";
const lake = convexLakeReaderFromEnv(
process.env,
{ urlVar: "LAKE_URL", tokenVar: "LAKE_READ_KEY" },
{
functions: { page: "lakeRead:listRows", get: "lakeRead:getRow" },
keyField: "rowKey",
tokenArg: "readKey", // omit to send the token through setAuth (Authorization: Bearer)
}
);
const app = buildHttpApp({ authMode: "oauth", authorizationServer, lake });The variable names, the query function names and the key field are configuration: nothing in the
package names a table or a function. The connection variables are declared with defineVariables
(the URL as url: https, http on loopback only; the token as a secret), so a missing or invalid
one is refused at start, by name, never by value. The adapter only ever calls
ConvexHttpClient.query, never a mutation or an action. The page function receives
{ after?, limit, tenantId? } and returns the rows whose key is after after, ascending, at most
limit (the adapter asks for one row more than the page to tell a truncated page from the last
one); the get function receives { key, tenantId? } and returns the row or null. An optional
parseRow (e.g. a Zod parse) types each row and refuses one it cannot read. A failing call is
read_failed, and the client's message is not forwarded: no error carries the token, the URL or a
row value.
Server-side i18n (FR/EN)
src/i18n.ts localizes MCP error/validation messages and the picker page: the
client sends X-MCP-Locale: fr|en (default en); the browser picker follows
Accept-Language. Resource files (src/locales/{en,fr}.json) keep the
<Namespace>.<category>.<key> convention of @vantageos/mosaic-i18n, FR and EN
in lockstep.
Testing
npm testRuns both transports through the real SDK code paths: the HTTP suite drives the
Hono app in-process with the SDK v2 Client; the stdio suite spawns the built
dist/stdio.js. npm run evals runs the declarative case-set against stdio.
Adding a tool
Declare its record in src/toolRecord.ts (authority pair, verb, table, and for a
list/search the mechanism and pagination), register it once in
src/mcpServerFactory.ts with title, annotations: YOUR_ANNOTATIONS,
inputSchema and outputSchema, and return structuredResult(...). It is then served on both transports and both
protocol eras. tests/toolRecordComplete.test.ts keeps it honest.
Packaging & distribution
Four manifests are generated from scripts/plugin-manifest.source.json and
package.json by scripts/sync-plugin-version.mjs (part of npm run build).
Never edit them by hand; tests/pluginManifests.test.ts fails on drift.
| File | For |
|---|---|
| .claude-plugin/plugin.json | Claude Code marketplace; non-standard fields (description_fr, category, bilingual) sit under metadata |
| .mcp.json | Claude Code: type: http, URL only, no secret |
| plugin.json + mcp.json | OpenAI Agent Plugins; streamable-http, auth under extensions["com.openai"].auth (OAuth, CIMD preferred, DCR fallback) |
The server URL is one placeholder in the source file. Set it for a real
deployment with MCP_SERVER_URL=https://your-host/mcp node scripts/sync-plugin-version.mjs
(both entries change), or leave Claude's ${MCP_BOILERPLATE_URL:-<placeholder>}
to be overridden by the installer. The onboarding skill is
skills/connect-mcp-server/SKILL.md, declared for OpenAI as
extensions["com.openai"].onboardingSkill.
Scaffold → build → publish
git clone https://github.com/elpiarthera/vantage-mcp-boilerplate.git my-mcp
cd my-mcp && npm install
# Rename package.json "name"/"bin" and the names in scripts/plugin-manifest.source.json,
# then add your tools in src/mcpServerFactory.ts.
rm -rf dist && npm run build && npm run build:ui && npm test && npm run evals
npm pack --dry-run # confirm dist/, manifests and skills/ ship
npm publish # prepublishOnly re-runs build + test + evalsPublication is proven at the registry's dist-tag, never by the exit code of
npm publish.
The version lives in package.json AND in mcp-server.json (the server manifest,
mcp-standard C-18). Bump both to the same value: scripts/sync-plugin-version.mjs
reads the version from the manifest and refuses a package.json that disagrees,
naming both. The manifest also declares the server's capabilities, which the factory
serves, and the feature switches later releases read.
Pin and verify the published package
Install from npm, the registry of record, and pin an EXACT version so an update never
arrives unasked. 1.2.3 below is an example, not a published version: list the real
ones and replace it with the version you audited.
npm view @vantageos/mcp-boilerplate versions --json # the published versions
npm install @vantageos/[email protected] # pin an exact versionRoll back by pinning the previous version; no published version is ever removed.
npm install @vantageos/[email protected] # rollback: the previous versionA second, independent channel carries the same tag: the GitHub release of
elpiarthera/vantage-mcp-boilerplate. The package is published with
npm publish --provenance by .github/workflows/release.yml; check the attestation
with npm audit signatures.
GitHub Actions is disabled across the ElPi Corp fleet, so a publication made without CI has no provenance attestation. The equivalent check is the checksum of the packed tarball, reproduced from a clean checkout of the tag and read back from the registry:
npm pack # prints the tarball name
shasum mcp-boilerplate-1.2.3.tgz # the checksum you reproduce (use the real name)
curl -s -H 'Cache-Control: no-cache' https://registry.npmjs.org/@vantageos%2Fmcp-boilerplate/1.2.3 \
| node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).dist.shasum"Equal sums: the registry serves what the source builds. A running server proves what
it serves the same way: GET /health returns { status, version, buildId } before any
authentication. buildId is a hash of the files the process runs from, so it changes
when they change, which version does not guarantee.
MCP Apps UI layer
list_items renders its page of EXAMPLE_ITEMS as an interactive widget
(io.modelcontextprotocol/ui, spec 2026-01-26). One operation on one table is one
tool (mcp-standard C-12), so the data tool itself carries the UI link; there is no
separate render tool.
- The tool links its
ui://resource through the nested_meta.ui.resourceUriwith explicitvisibility: ["model", "app"]. The tool is registered withserver.registerTool, not ext-apps'registerAppTool, because that helper also writes the deprecated flat_meta["ui/resourceUri"]key (mcp-app-standard A-6). - The resource declares
_meta.ui:cspwithconnectDomains,resourceDomains,frameDomainsandbaseUriDomains(all empty — the widget makes no network request, loads no third-party asset and embeds no frame),prefersBorder, anddomainonly whenMCP_UI_DOMAINis set (the format is host-dependent). Its mime type istext/html;profile=mcp-app. - ChatGPT keys are additive:
openai/outputTemplate(same URI) and theopenai/toolInvocation/invoking|invokedstrings. Nothing reads them; the server works with everyopenai/*key absent.openai/uientrypoints are not emitted — they are optional; add them to the tool_metainsrc/ui/uiResource.tsif your ChatGPT app needs them. - A markdown fallback is always in the call result, for clients that never negotiate the extension.
The widget (src/ui/) reuses a vendored MosaicCard family and is bundled to
one self-contained HTML file (an in-repo inlining plugin in vite.config.ts,
dist/ui-bundle/widget.html) by npm run build:ui, on a path that never
collides with tsc's dist/ui/.
Identity layer: @vantageos/cloud-identity (^0.20.2) is the only identity
package. Its resource-server entry point (verifyMcpAccessToken,
buildProtectedResourceMetadata) verifies the Bearer token on /mcp; jose is
kept only to mint the authorization server's access tokens. An unreachable key set
answers 503, never 401.
