npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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:http

Protocol 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/discover and resources/* results carry resultType, ttlMs and cacheScope from the SDK's cacheHints (set once in the factory);
  • a 2025-era client opens with initialize and 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:

  1. Use a Clerk PRODUCTION instance on the server's own registrable domain. A server at mcp.example.com takes the Clerk domain example.com, so Clerk serves clerk.example.com and accounts.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 URI https://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).
  2. 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-in on production, https://<instance>.accounts.dev/sign-in on 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 to localhost / the Home URL (development, measured). This is why step 1 puts the server and the instance on one domain. Measured: the Backend API redirect_urls list is not consulted by the hosted pages.
  3. A development instance is for local or test use only. Its hosted sign-in page redirects only to localhost or 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.
  4. How the session reaches the server. After sign-in the browser comes back to /authorize/callback without a session on your host. The server resolves it with Clerk's own handshake (@vantageos/cloud-identity 0.19.0, built on @clerk/backend authenticateRequest()): it redirects the browser through Clerk's handshake endpoint and Clerk sets __session on your host. It needs CLERK_SECRET_KEY and the publishable key: CLERK_PUBLISHABLE_KEY, or, when unset, derived from CLERK_DOMAIN as pk_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.
  5. Boot warning. When the configured instance is a development one (an sk_test_ key, or a CLERK_DOMAIN under accounts.dev) and PUBLIC_BASE_URL is 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.
  6. 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_NAME to 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 call

allowedOrigins 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 InvalidCursorError

Limit: 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 default

The 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 test

Runs 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 + evals

Publication 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 version

Roll back by pinning the previous version; no published version is ever removed.

npm install @vantageos/[email protected]             # rollback: the previous version

A 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.resourceUri with explicit visibility: ["model", "app"]. The tool is registered with server.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: csp with connectDomains, resourceDomains, frameDomains and baseUriDomains (all empty — the widget makes no network request, loads no third-party asset and embeds no frame), prefersBorder, and domain only when MCP_UI_DOMAIN is set (the format is host-dependent). Its mime type is text/html;profile=mcp-app.
  • ChatGPT keys are additive: openai/outputTemplate (same URI) and the openai/toolInvocation/invoking|invoked strings. Nothing reads them; the server works with every openai/* key absent. openai/ui entrypoints are not emitted — they are optional; add them to the tool _meta in src/ui/uiResource.ts if 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.