@vantageos/mcp-boilerplate 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +16 -8
  2. package/.mcp.json +8 -0
  3. package/CHANGELOG.md +67 -1
  4. package/README.fr.md +177 -142
  5. package/README.md +170 -133
  6. package/dist/auth.d.ts +20 -10
  7. package/dist/auth.js +9 -8
  8. package/dist/config.d.ts +10 -3
  9. package/dist/config.js +31 -3
  10. package/dist/exampleItems.d.ts +5 -4
  11. package/dist/exampleItems.js +3 -0
  12. package/dist/http.js +11 -1
  13. package/dist/httpApp.d.ts +23 -30
  14. package/dist/httpApp.js +73 -74
  15. package/dist/i18n.d.ts +6 -0
  16. package/dist/i18n.js +9 -0
  17. package/dist/locales/en.json +9 -0
  18. package/dist/locales/fr.json +9 -0
  19. package/dist/mcpServerFactory.d.ts +42 -41
  20. package/dist/mcpServerFactory.js +114 -165
  21. package/dist/oauth/authorizationServer.d.ts +56 -0
  22. package/dist/oauth/authorizationServer.js +423 -0
  23. package/dist/oauth/clerk.d.ts +15 -0
  24. package/dist/oauth/clerk.js +59 -0
  25. package/dist/oauth/clientRegistry.d.ts +50 -0
  26. package/dist/oauth/clientRegistry.js +203 -0
  27. package/dist/oauth/codeStore.d.ts +11 -0
  28. package/dist/oauth/codeStore.js +28 -0
  29. package/dist/oauth/picker.d.ts +11 -0
  30. package/dist/oauth/picker.js +67 -0
  31. package/dist/oauth/settings.d.ts +48 -0
  32. package/dist/oauth/settings.js +100 -0
  33. package/dist/oauth/tokenIssuer.d.ts +22 -0
  34. package/dist/oauth/tokenIssuer.js +61 -0
  35. package/dist/stdio.d.ts +7 -6
  36. package/dist/stdio.js +20 -19
  37. package/dist/tenancy.d.ts +16 -21
  38. package/dist/tenancy.js +12 -26
  39. package/dist/ui/uiResource.d.ts +39 -6
  40. package/dist/ui/uiResource.js +62 -13
  41. package/dist/version.d.ts +2 -0
  42. package/dist/version.js +8 -0
  43. package/docs/architecture.md +82 -0
  44. package/docs/oauth-stores.md +60 -0
  45. package/examples/oauth.env.example +25 -0
  46. package/mcp.json +19 -0
  47. package/package.json +23 -13
  48. package/plugin.json +43 -0
  49. package/scripts/generate-signing-key.mjs +14 -0
  50. package/scripts/plugin-manifest.source.json +35 -0
  51. package/skills/connect-mcp-server/SKILL.md +40 -0
package/README.md CHANGED
@@ -1,93 +1,141 @@
1
1
  # @vantageos/mcp-boilerplate
2
2
 
3
- SELLABLE AS: `mcp-boilerplate` — a clonable MCP server starter for the Claude Code marketplace / VantageRegistry catalogue, published as `@vantageos/mcp-boilerplate` on npm.
3
+ 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.
4
4
 
5
- A clonable MCP server skeleton: dual-transport bootstrap (stdio + Streamable
6
- HTTP, stateless) built exclusively on the official
7
- [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk)
8
- transports. No custom REST routes ever stand in front of a tool call.
5
+ A clonable MCP server skeleton on the official
6
+ [`@modelcontextprotocol/server`](https://www.npmjs.com/package/@modelcontextprotocol/server)
7
+ SDK v2: protocol **2026-07-28** with the 2025 handshake still served, stdio and
8
+ stateless Streamable HTTP from one server definition, its **own OAuth 2.1
9
+ authorization server** (a Clerk person picks an organisation), an MCP Apps UI
10
+ and FR/EN messages. No custom REST route ever stands in front of a tool call.
9
11
 
10
- This is a foundation, not a finished product: it ships one trivial example
11
- tool (`ping`) whose only purpose is to prove the MCP handshake works on both
12
- transports. Later layers (protocol-kit result types, auth, multi-tenant
13
- scoping, UI extensions, i18n) build on top of this bootstrap without
14
- changing the transport wiring.
12
+ It ships three small example tools (`ping`, `list_items`, `render_items_ui`)
13
+ that exercise every layer. Replace them with yours; the wiring stays.
15
14
 
16
15
  ## Structure
17
16
 
18
17
  ```
19
18
  src/
20
19
  mcpServerFactory.ts # single MCP server definition, shared by both transports
21
- stdio.ts # official SDK StdioServerTransport entrypoint
20
+ version.ts # server name + version, derived from package.json
21
+ stdio.ts # serveStdio(factory): both protocol eras over stdio
22
22
  http.ts # Node http.Server adapter around the Hono app
23
- httpApp.ts # official SDK WebStandardStreamableHTTPServerTransport (stateless)
24
- auth.ts # OAuth 2.1 resource-server wiring (@vantageos/mcp-auth-kit)
25
- config.ts # env-derived AuthMode + AuthConfig loaders
26
- index.ts # package exports
27
- tests/
28
- helpers/mcpRpc.ts # JSON-RPC request helpers for the HTTP transport
29
- helpers/authFixtures.ts # test-only RS256 JWT signing fixtures
30
- httpHandshake.test.ts # initialize -> tools/list -> tools/call over Streamable HTTP
31
- stdioHandshake.test.ts # same handshake over a real spawned stdio child process
32
- oauth.test.ts # both auth tiers: oauth gate + public no-auth tier
33
- docs/
34
- architecture.md # wire posture: tool-level vs wire-level protocol version
23
+ httpApp.ts # createMcpHandler(factory) on /mcp + the OAuth gate
24
+ auth.ts # resource-server wiring (@vantageos/cloud-identity)
25
+ config.ts # env-derived AuthMode + external-issuer AuthConfig
26
+ tenancy.ts # verified JWT -> tenant; rows filtered by stored orgId
27
+ oauth/ # own authorization server (cloud-identity decides)
28
+ authorizationServer.ts settings.ts clerk.ts tokenIssuer.ts
29
+ clientRegistry.ts codeStore.ts picker.ts
30
+ ui/ # MCP Apps widget + ui:// resource
31
+ skills/connect-mcp-server/SKILL.md # onboarding skill (English)
32
+ scripts/
33
+ plugin-manifest.source.json # the ONE source of every packaging manifest
34
+ sync-plugin-version.mjs # generates the four manifests (runs in `npm run build`)
35
+ generate-signing-key.mjs # prints an RS256 private JWK for MCP_OAUTH_SIGNING_JWK
36
+ tests/ evals/ docs/
35
37
  ```
36
38
 
37
39
  ## Quick start
38
40
 
39
41
  ```bash
40
42
  npm install
41
- npm run build
43
+ npm run build && npm run build:ui
42
44
 
43
45
  # stdio (local client config, e.g. Claude Code / Cursor) — never gated,
44
46
  # the local process invoking stdio IS the caller.
45
47
  npm run start:stdio
46
48
 
47
- # Streamable HTTP (remote, stateless) — OAuth 2.1 gate by default:
48
- MCP_OAUTH_ISSUER=https://your-tenant.example.com \
49
- MCP_OAUTH_AUDIENCE=https://your-mcp-server.example.com \
50
- PORT=8787 npm run start:http
51
-
52
- # Streamable HTTP, no-auth "Public MCP" tier (local/self-host, trusted caller):
49
+ # Streamable HTTP, public tier (local/self-host, trusted caller):
53
50
  AUTH_MODE=public PORT=8787 npm run start:http
51
+
52
+ # Streamable HTTP, OAuth tier (default): this server is its own authorization
53
+ # server. See examples/oauth.env.example for every required variable.
54
+ node scripts/generate-signing-key.mjs # -> MCP_OAUTH_SIGNING_JWK
55
+ PORT=8787 npm run start:http
54
56
  ```
55
57
 
56
- ## Authentication tiers
58
+ ## Protocol and transports
59
+
60
+ One factory (`createBoilerplateMcpServer`) builds a fresh `McpServer` per
61
+ request (HTTP) or per connection (stdio). The SDK owns the era decision:
57
62
 
58
- Two tiers, per `mcp-standard.md` "Authentication & Authorization":
63
+ - a **2026-07-28** client opens with `server/discover`; `tools/list`,
64
+ `server/discover` and `resources/*` results carry `resultType`, `ttlMs` and
65
+ `cacheScope` from the SDK's `cacheHints` (set once in the factory);
66
+ - a **2025-era** client opens with `initialize` and is served through the SDK's
67
+ stateless legacy leg on HTTP, or a pinned connection on stdio.
68
+
69
+ The 2025 floor is **2025-06-18**: the SDK's `supportedProtocolVersions` option
70
+ lists only `2025-11-25` and `2025-06-18`, so an older `initialize` is
71
+ counter-offered `2025-11-25`, never served at its own version. The advertised
72
+ server version is read from `package.json`.
73
+
74
+ ## Tool records
75
+
76
+ Every tool declares `title`, the four annotations as explicit booleans
77
+ (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), a typed
78
+ input schema and a typed output schema (Zod 4), and returns `structuredContent`
79
+ plus a text block with the same JSON. `tests/toolRecordComplete.test.ts` walks
80
+ `tools/list` and fails on any gap. Annotations are untrusted hints for the
81
+ client (mcp-standard C-15); never gate an action on them alone.
82
+
83
+ ## Authentication tiers
59
84
 
60
85
  | `AUTH_MODE` | Behavior |
61
86
  |---|---|
62
- | `oauth` (default) | OAuth 2.1 resource server (`@vantageos/mcp-auth-kit`): every `/mcp` HTTP call requires a valid RS256 Bearer JWT, audience/issuer-bound. Missing/invalid token -> `401` + `WWW-Authenticate`. RFC 9728 Protected Resource Metadata served at `/.well-known/oauth-protected-resource`. `/health` stays token-free. Requires `MCP_OAUTH_ISSUER` + `MCP_OAUTH_AUDIENCE`. |
63
- | `public` | No gate at all on the HTTP transport — for local/self-host use where the caller is already trusted. Mirrors the stdio transport's trust model (stdio never had a gate). No `.well-known/oauth-protected-resource` document is served in this mode (there is no authorization server to describe). |
64
-
65
- The stdio transport (`stdio.ts`) is never gated in either mode — the local
66
- process invoking it IS the caller, per `mcp-standard.md`'s trust model for
67
- local/self-host tools.
68
-
69
- `buildHttpApp()` called with no arguments defaults to the `public` tier — this
70
- is what the transport-skeleton tests (`httpHandshake.test.ts`,
71
- `protocolKitWiring.test.ts`) exercise, so they need no OAuth configuration.
72
- The production entrypoint (`http.ts`) always resolves `AUTH_MODE` from the
73
- environment via `config.ts`, defaulting to `oauth` when unset.
74
-
75
- ## Server-side i18n (FR/EN error messages)
76
-
77
- `src/i18n.ts` localizes MCP error/validation messages (auth rejection, tenant
78
- refusal, tool input-validation) per `mcp-standard.md`'s bilingual mandate
79
- (§Bilingual): the client sends `X-MCP-Locale: fr` or `X-MCP-Locale: en`
80
- (default `en` when absent), and `src/httpApp.ts` + `src/mcpServerFactory.ts`
81
- route the corresponding message through `t(locale, key)`.
82
-
83
- The two resource files (`src/locales/{en,fr}.json`) follow the exact
84
- namespaced-resource convention shipped by `@vantageos/mosaic-i18n@0.1.1`
85
- (`node_modules/@vantageos/mosaic-i18n/src/locales/{en,fr}.json`): a flat
86
- `<Namespace>.<category>.<key>` -> string object, FR and EN kept key-for-key
87
- in lockstep. `mosaic-i18n` itself is a React/`react-i18next` package and
88
- cannot be called directly from a JSON-RPC server, so its runtime API is not
89
- imported here — its resource-file shape is reused instead, under this
90
- boilerplate's own `Mcp` namespace.
87
+ | `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. |
88
+ | `public` | No gate on HTTP — local/self-host, trusted caller. No authorization-server document is served. |
89
+
90
+ stdio is never gated in either mode.
91
+
92
+ ### Own authorization server (ruling R1)
93
+
94
+ | Route | Role |
95
+ |---|---|
96
+ | `GET /.well-known/oauth-authorization-server` (+ `openid-configuration`) | RFC 8414 metadata: S256 only, RFC 9207 `iss`, CIMD and DCR advertised |
97
+ | `GET /.well-known/oauth-protected-resource/mcp` | RFC 9728 PRM (`resource` = `<base>/mcp`) via cloud-identity |
98
+ | `GET /.well-known/jwks.json` | public signing keys only |
99
+ | `POST /register` | Dynamic Client Registration fallback (public clients, PKCE) |
100
+ | `GET /authorize`, `GET /authorize/callback`, `POST /authorize/org` | Clerk sign-in, then the person + organisation picker with consent |
101
+ | `POST /token` | `authorization_code` + PKCE S256 -> RS256 JWT bound to the resource, the issuer and the chosen organisation |
102
+ | `GET /userinfo` | OIDC subject (and email when the scope allows) |
103
+
104
+ Every decision — redirect/PKCE/resource/scope checks, the consent token, code
105
+ binding and redemption, discovery and userinfo shapes — is made by
106
+ `@vantageos/cloud-identity` (`startAuthorize`, `resumeAuthorize`,
107
+ `exchangeAuthorizationCode`, `buildDiscoveryDocument`, `buildUserInfo`). This app
108
+ supplies the environment, the stores, the Clerk calls, the picker page and the
109
+ signature. A client may identify itself by a Client ID Metadata Document URL
110
+ (fetched SSRF-guarded) or register through `/register`.
111
+
112
+ **Fail-closed**: a missing variable (`PUBLIC_BASE_URL`, `CLERK_DOMAIN`,
113
+ `AUTHORIZE_SIGN_IN_URL`, `AUTHORIZE_CALLBACK_URL`, `AUTHORIZE_STATE_SECRET`,
114
+ `CLERK_SECRET_KEY`, `MCP_OAUTH_SIGNING_JWK`) answers `503` and is named in the
115
+ log (names, never values). There is no auto-approve path.
116
+
117
+ **Stores**: the authorization-code store and the client registry are interfaces
118
+ with in-memory adapters for tests and single-process development. Use durable
119
+ adapters in production — contract in [`docs/oauth-stores.md`](docs/oauth-stores.md).
120
+
121
+ **Not included**: refresh tokens (access tokens last one hour), per-organisation
122
+ API keys for non-ChatGPT clients (this code never had that path; it is a gap, not
123
+ a regression), wildcard redirect URIs, and a token revocation list.
124
+
125
+ ### Multi-tenancy
126
+
127
+ `list_items` filters rows by their stored `orgId` against the caller's verified
128
+ organisation (`isRowInTenant`); a token with no organisation gets an explicit
129
+ tool refusal, never an unscoped view. Resolve the caller's tenant only from the
130
+ verified token, never from a tool argument.
131
+
132
+ ## Server-side i18n (FR/EN)
133
+
134
+ `src/i18n.ts` localizes MCP error/validation messages and the picker page: the
135
+ client sends `X-MCP-Locale: fr|en` (default `en`); the browser picker follows
136
+ `Accept-Language`. Resource files (`src/locales/{en,fr}.json`) keep the
137
+ `<Namespace>.<category>.<key>` convention of `@vantageos/mosaic-i18n`, FR and EN
138
+ in lockstep.
91
139
 
92
140
  ## Testing
93
141
 
@@ -95,91 +143,80 @@ boilerplate's own `Mcp` namespace.
95
143
  npm test
96
144
  ```
97
145
 
98
- Runs both transports against the real official-SDK code paths: the HTTP
99
- suite drives the Hono app in-process; the stdio suite spawns the built
100
- `dist/stdio.js` binary as a real child process via `StdioClientTransport`.
146
+ Runs both transports through the real SDK code paths: the HTTP suite drives the
147
+ Hono app in-process with the SDK v2 `Client`; the stdio suite spawns the built
148
+ `dist/stdio.js`. `npm run evals` runs the declarative case-set against stdio.
101
149
 
102
150
  ## Adding a tool
103
151
 
104
- Register it once in `src/mcpServerFactory.ts` — it is then available on
105
- both transports automatically. There is no separate REST route to add.
152
+ Register it once in `src/mcpServerFactory.ts` with `title`, the four
153
+ annotations, `inputSchema` and `outputSchema`, and return
154
+ `structuredResult(...)`. It is then served on both transports and both
155
+ protocol eras. `tests/toolRecordComplete.test.ts` keeps it honest.
106
156
 
107
157
  ## Packaging & distribution
108
158
 
109
- This boilerplate is itself a publishable/clonable npm package. Two artifacts
110
- carry its distribution identity:
111
-
112
- - `package.json` — the npm package. `files` ships only the built `dist/` tree
113
- (compiled server + `dist/locales/` + `dist/ui-bundle/widget.html`), the
114
- `README.md`, the `LICENSE`, the plugin manifest, and the example client
115
- config — never `src/`, tests, or tooling. `main`/`types`/`exports` point at
116
- `dist/index.js`; the `mcp-boilerplate` bin points at `dist/stdio.js`.
117
- `publishConfig.access = public` is required for the `@vantageos` scope.
118
- - `.claude-plugin/plugin.json` — the Claude Code marketplace / VantageRegistry
119
- catalogue manifest, following the fleet MCP-server convention (mirrors
120
- `vantage-frameworks-mcp` / `vantage-agent-composer-mcp`): `name`, `version`,
121
- bilingual `description` + `description_fr`, `author`, `license`, `homepage`,
122
- `category: "mcp-server"`, `bilingual: true`, `keywords`.
159
+ Four manifests are **generated** from `scripts/plugin-manifest.source.json` and
160
+ `package.json` by `scripts/sync-plugin-version.mjs` (part of `npm run build`).
161
+ Never edit them by hand; `tests/pluginManifests.test.ts` fails on drift.
162
+
163
+ | File | For |
164
+ |---|---|
165
+ | `.claude-plugin/plugin.json` | Claude Code marketplace; non-standard fields (`description_fr`, `category`, `bilingual`) sit under `metadata` |
166
+ | `.mcp.json` | Claude Code: `type: http`, URL only, no secret |
167
+ | `plugin.json` + `mcp.json` | OpenAI Agent Plugins; `streamable-http`, auth under `extensions["com.openai"].auth` (OAuth, CIMD preferred, DCR fallback) |
168
+
169
+ The server URL is one placeholder in the source file. Set it for a real
170
+ deployment with `MCP_SERVER_URL=https://your-host/mcp node scripts/sync-plugin-version.mjs`
171
+ (both entries change), or leave Claude's `${MCP_BOILERPLATE_URL:-<placeholder>}`
172
+ to be overridden by the installer. The onboarding skill is
173
+ `skills/connect-mcp-server/SKILL.md`, declared for OpenAI as
174
+ `extensions["com.openai"].onboardingSkill`.
123
175
 
124
176
  ### Scaffold → build → publish
125
177
 
126
178
  ```bash
127
- # 1. Scaffold: clone this repo (or the npm tarball) as your server's skeleton.
128
179
  git clone https://github.com/elpiarthera/vantage-mcp-boilerplate.git my-mcp
129
- cd my-mcp && npm install --legacy-peer-deps # see dev peer-range note above
130
-
131
- # 2. Rename: set package.json "name"/"bin" and .claude-plugin/plugin.json
132
- # "name" to your server, then add tools in src/mcpServerFactory.ts.
133
-
134
- # 3. Build + verify from a clean tree (what CI and prepublishOnly enforce):
135
- rm -rf dist node_modules/.vite && npm run build && npm test && npm run evals
136
-
137
- # 4. Inspect the tarball before publishing — confirm dist/ ships and nothing leaks:
138
- npm pack --dry-run
139
-
140
- # 5. Publish to the two channels:
141
- npm publish # npm registry (prepublishOnly re-runs build+test+evals)
142
- # VantageRegistry catalogue: register .claude-plugin/plugin.json via
143
- # the fleet create-plugin / generate-marketplace skill (VR upsert).
180
+ cd my-mcp && npm install
181
+ # Rename package.json "name"/"bin" and the names in scripts/plugin-manifest.source.json,
182
+ # then add your tools in src/mcpServerFactory.ts.
183
+ rm -rf dist && npm run build && npm run build:ui && npm test && npm run evals
184
+ npm pack --dry-run # confirm dist/, manifests and skills/ ship
185
+ npm publish # prepublishOnly re-runs build + test + evals
144
186
  ```
145
187
 
146
- `prepublishOnly` re-runs `build → build:ui → test → evals` so a broken or
147
- src-only tarball can never reach npm. The distribution channels are **npm**
148
- (the runnable package) and the **VantageRegistry / Claude Code marketplace
149
- catalogue** (discovery, via `plugin.json`).
188
+ Publication is proven at the registry's `dist-tag`, never by the exit code of
189
+ `npm publish`.
150
190
 
151
191
  ## MCP Apps UI layer
152
192
 
153
- `render_items_ui` renders `EXAMPLE_ITEMS` as an interactive widget via the
154
- official `@modelcontextprotocol/ext-apps/server` (`registerAppTool` +
155
- `registerAppResource`), GA `_meta.ui.resourceUri` nested form (stable
156
- 2026-01-26) — never the deprecated flat `_meta["ui/resourceUri"]` key. A
157
- markdown fallback is always present in `content[0]`, even for clients that
158
- never negotiate the `io.modelcontextprotocol/ui` extension.
159
-
160
- The widget (`src/ui/`) reuses a vendored `MosaicCard` family
161
- (`src/ui/mosaic-card.tsx` — style-only presentational atoms, no runtime
162
- dependency) and is bundled to a single self-contained HTML file
163
- (`vite-plugin-singlefile`, `dist/ui-bundle/widget.html`, ~70 KB gzipped, no
164
- external runtime fetch). `npm run build:ui` produces this bundle
165
- independently of `npm run build` (tsc), on a non-colliding output path
166
- (`dist/ui-bundle/` vs tsc's `dist/ui/`) — the two build steps write to the
167
- same-looking `dist/ui*` tree but never share a directory, so
168
- `emptyOutDir` on the vite side can never delete a tsc-compiled server
169
- module.
170
-
171
- **Template hygiene**: the `MosaicCard` atoms are vendored locally rather
172
- than pulled from `@vantageos/mosaic-blocks`, an internal package with no
173
- stable (non-alpha) npm release. A fork-template devDependency pinned to a
174
- moving alpha tag breaks on the next alpha bump; vendoring the four
175
- presentational primitives the widget actually uses removes that failure
176
- mode entirely.
177
-
178
- **Publish ordering (hard requirement)**: this boilerplate depends on
179
- `@vantageos/mcp-protocol-kit@^0.2.0`. The `tools/list` wiring uses the kit's
180
- `buildToolsListResult`, which exists ONLY in kit 0.2.0+. In 0.x semver `^0.2.0`
181
- does NOT accept 0.1.x, so a fresh `npm install` from the registry resolves the
182
- kit build the code actually needs. Therefore **kit `@vantageos/mcp-protocol-kit`
183
- 0.2.0 MUST be published to npm BEFORE this boilerplate can be installed or
184
- published from the registry.** This is a strict ordering, not optional: publish
185
- the kit first, confirm 0.2.0 is live at the registry, then the boilerplate.
193
+ `render_items_ui` renders `EXAMPLE_ITEMS` as an interactive widget
194
+ (`io.modelcontextprotocol/ui`, spec 2026-01-26).
195
+
196
+ - The tool links its `ui://` resource through the nested `_meta.ui.resourceUri`
197
+ with explicit `visibility: ["model", "app"]`. The tool is registered with
198
+ `server.registerTool`, not ext-apps' `registerAppTool`, because that helper
199
+ also writes the deprecated flat `_meta["ui/resourceUri"]` key (mcp-app-standard A-6).
200
+ - The resource declares `_meta.ui`: `csp` with `connectDomains`,
201
+ `resourceDomains`, `frameDomains` and `baseUriDomains` (all empty — the widget
202
+ makes no network request, loads no third-party asset and embeds no frame),
203
+ `prefersBorder`, and `domain` only when `MCP_UI_DOMAIN` is set (the format is
204
+ host-dependent). Its mime type is `text/html;profile=mcp-app`.
205
+ - **ChatGPT keys are additive**: `openai/outputTemplate` (same URI) and the
206
+ `openai/toolInvocation/invoking|invoked` strings. Nothing reads them; the
207
+ server works with every `openai/*` key absent. `openai/ui` entrypoints are
208
+ not emitted — they are optional; add them to the tool `_meta` in
209
+ `src/ui/uiResource.ts` if your ChatGPT app needs them.
210
+ - A markdown fallback is always in the call result, for clients that never
211
+ negotiate the extension.
212
+
213
+ The widget (`src/ui/`) reuses a vendored `MosaicCard` family and is bundled to
214
+ one self-contained HTML file (`vite-plugin-singlefile`,
215
+ `dist/ui-bundle/widget.html`) by `npm run build:ui`, on a path that never
216
+ collides with tsc's `dist/ui/`.
217
+
218
+ **Identity layer**: `@vantageos/cloud-identity` (^0.15.0) is the only identity
219
+ package. Its resource-server entry point (`verifyMcpAccessToken`,
220
+ `buildProtectedResourceMetadata`) verifies the Bearer token on `/mcp`; `jose` is
221
+ kept only to mint the authorization server's access tokens. An unreachable key set
222
+ answers 503, never 401.
package/dist/auth.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * OAuth 2.1 resource-server wiring for this MCP server's HTTP transport.
3
3
  *
4
- * Reuses @vantageos/mcp-auth-kit's `verifyMcpBearerRequest` facade
5
- * unmodified — this module never hand-rolls JWT verification. It only
6
- * supplies configuration (issuer, audience, JWKS resolver, resource
7
- * metadata URL) and adapts the facade's result to a plain
8
- * accept/reject decision for the HTTP transport.
4
+ * Built on @vantageos/cloud-identity's resource-server entry point
5
+ * (`verifyMcpAccessToken`, `buildProtectedResourceMetadata`) — the ONLY identity layer of this
6
+ * boilerplate. This module never hand-rolls JWT verification. It only supplies configuration
7
+ * (issuer, audience, key set, resource metadata URL) and returns the verifier's result to the
8
+ * HTTP transport: accepted (with the verified session), refused 401 (with the RFC 6750
9
+ * challenge), or 503 when the key set is unreachable (an outage, never a bad credential).
9
10
  *
10
11
  * This is the "OAuth 2.1" tier of the two auth tiers this boilerplate
11
12
  * supports (see config.ts `AuthMode`). The other tier — "public" — never
@@ -13,15 +14,24 @@
13
14
  * stdio transport (stdio.ts) never had a gate to begin with (local process
14
15
  * trust model, per mcp-standard.md).
15
16
  */
16
- import { type McpResourceServerResult } from "@vantageos/mcp-auth-kit";
17
- import type { JWTVerifyGetKey } from "jose";
17
+ import { type ClerkJwks, type VerifyMcpAccessTokenResult } from "@vantageos/cloud-identity";
18
18
  export interface AuthConfig {
19
19
  realm: string;
20
20
  resourceMetadataUrl: string;
21
- jwks: JWTVerifyGetKey;
21
+ /**
22
+ * The protected-resource identifier published in the PRM (RFC 9728). Defaults to the origin
23
+ * of `resourceMetadataUrl` (the pre-0.2 behaviour, kept for external-issuer deployments);
24
+ * the own-authorization-server tier sets it to `<base>/mcp`.
25
+ */
26
+ resource?: string;
27
+ /**
28
+ * The signing keys: a `{ keys }` JWK set, or a function returning one. The consumer owns
29
+ * fetching and caching (config.ts `createCachedJwksFetcher` for an external issuer).
30
+ */
31
+ jwks: ClerkJwks | (() => Promise<ClerkJwks>);
22
32
  issuer: string;
23
33
  audience: string;
24
34
  authorizationServers: string[];
25
35
  }
26
- export declare function authenticateRequest(authHeader: string | undefined, config: AuthConfig): Promise<McpResourceServerResult>;
27
- export declare function protectedResourceMetadataDocument(config: AuthConfig): import("@vantageos/mcp-auth-kit").ProtectedResourceMetadataDocument;
36
+ export declare function authenticateRequest(authHeader: string | undefined, config: AuthConfig): Promise<VerifyMcpAccessTokenResult>;
37
+ export declare function protectedResourceMetadataDocument(config: AuthConfig): import("@vantageos/cloud-identity").ProtectedResourceMetadata;
package/dist/auth.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * OAuth 2.1 resource-server wiring for this MCP server's HTTP transport.
3
3
  *
4
- * Reuses @vantageos/mcp-auth-kit's `verifyMcpBearerRequest` facade
5
- * unmodified — this module never hand-rolls JWT verification. It only
6
- * supplies configuration (issuer, audience, JWKS resolver, resource
7
- * metadata URL) and adapts the facade's result to a plain
8
- * accept/reject decision for the HTTP transport.
4
+ * Built on @vantageos/cloud-identity's resource-server entry point
5
+ * (`verifyMcpAccessToken`, `buildProtectedResourceMetadata`) — the ONLY identity layer of this
6
+ * boilerplate. This module never hand-rolls JWT verification. It only supplies configuration
7
+ * (issuer, audience, key set, resource metadata URL) and returns the verifier's result to the
8
+ * HTTP transport: accepted (with the verified session), refused 401 (with the RFC 6750
9
+ * challenge), or 503 when the key set is unreachable (an outage, never a bad credential).
9
10
  *
10
11
  * This is the "OAuth 2.1" tier of the two auth tiers this boilerplate
11
12
  * supports (see config.ts `AuthMode`). The other tier — "public" — never
@@ -13,9 +14,9 @@
13
14
  * stdio transport (stdio.ts) never had a gate to begin with (local process
14
15
  * trust model, per mcp-standard.md).
15
16
  */
16
- import { buildProtectedResourceMetadata, verifyMcpBearerRequest, } from "@vantageos/mcp-auth-kit";
17
+ import { buildProtectedResourceMetadata, verifyMcpAccessToken, } from "@vantageos/cloud-identity";
17
18
  export async function authenticateRequest(authHeader, config) {
18
- return verifyMcpBearerRequest(authHeader, {
19
+ return verifyMcpAccessToken(authHeader, {
19
20
  realm: config.realm,
20
21
  resourceMetadataUrl: config.resourceMetadataUrl,
21
22
  jwks: config.jwks,
@@ -25,7 +26,7 @@ export async function authenticateRequest(authHeader, config) {
25
26
  }
26
27
  export function protectedResourceMetadataDocument(config) {
27
28
  return buildProtectedResourceMetadata({
28
- resource: config.resourceMetadataUrl.replace(/\/\.well-known\/.*/, ""),
29
+ resource: config.resource ?? config.resourceMetadataUrl.replace(/\/\.well-known\/.*/, ""),
29
30
  authorizationServers: config.authorizationServers,
30
31
  });
31
32
  }
package/dist/config.d.ts CHANGED
@@ -13,9 +13,16 @@
13
13
  * URL, issuer, audience) comes from the environment, never a literal in
14
14
  * code.
15
15
  */
16
- import { type JWTVerifyGetKey, createLocalJWKSet } from "jose";
16
+ import type { ClerkJwks } from "@vantageos/cloud-identity";
17
17
  import type { AuthConfig } from "./auth.js";
18
18
  export type AuthMode = "oauth" | "public";
19
19
  export declare function loadAuthMode(env?: NodeJS.ProcessEnv): AuthMode;
20
- export declare function loadAuthConfig(env?: NodeJS.ProcessEnv, jwksOverride?: JWTVerifyGetKey): AuthConfig;
21
- export { createLocalJWKSet };
20
+ export declare function loadAuthConfig(env?: NodeJS.ProcessEnv, jwksOverride?: AuthConfig["jwks"]): AuthConfig;
21
+ /**
22
+ * Fetch-and-cache for an external issuer's JWKS. cloud-identity's resource server takes the key
23
+ * set (or a function returning it) and owns no network I/O, so this boilerplate owns it: one
24
+ * GET of the JWKS document, cached for ten minutes. A failed fetch throws — the verifier turns
25
+ * that into a 503 (`jwks-unavailable`), never a 401 — unless a previously fetched set is still
26
+ * held, which is served rather than failing every request during a brief issuer blip.
27
+ */
28
+ export declare function createCachedJwksFetcher(url: string, fetchImpl?: typeof fetch, nowMs?: () => number): () => Promise<ClerkJwks>;
package/dist/config.js CHANGED
@@ -13,7 +13,6 @@
13
13
  * URL, issuer, audience) comes from the environment, never a literal in
14
14
  * code.
15
15
  */
16
- import { createLocalJWKSet, createRemoteJWKSet } from "jose";
17
16
  export function loadAuthMode(env = process.env) {
18
17
  const raw = (env.AUTH_MODE ?? "oauth").toLowerCase();
19
18
  if (raw !== "oauth" && raw !== "public") {
@@ -30,7 +29,7 @@ export function loadAuthConfig(env = process.env, jwksOverride) {
30
29
  "oauth mode (OAuth 2.1 resource-server requirement — mcp-standard.md Critical Rule #14). " +
31
30
  "Set AUTH_MODE=public instead to run the no-auth local/self-host tier.");
32
31
  }
33
- const jwks = jwksOverride ?? createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
32
+ const jwks = jwksOverride ?? createCachedJwksFetcher(`${issuer}/.well-known/jwks.json`);
34
33
  return {
35
34
  realm: "mcp",
36
35
  resourceMetadataUrl: `${publicBaseUrl}/.well-known/oauth-protected-resource`,
@@ -40,4 +39,33 @@ export function loadAuthConfig(env = process.env, jwksOverride) {
40
39
  authorizationServers: [issuer],
41
40
  };
42
41
  }
43
- export { createLocalJWKSet };
42
+ const JWKS_TTL_MS = 10 * 60 * 1000;
43
+ /**
44
+ * Fetch-and-cache for an external issuer's JWKS. cloud-identity's resource server takes the key
45
+ * set (or a function returning it) and owns no network I/O, so this boilerplate owns it: one
46
+ * GET of the JWKS document, cached for ten minutes. A failed fetch throws — the verifier turns
47
+ * that into a 503 (`jwks-unavailable`), never a 401 — unless a previously fetched set is still
48
+ * held, which is served rather than failing every request during a brief issuer blip.
49
+ */
50
+ export function createCachedJwksFetcher(url, fetchImpl = fetch, nowMs = Date.now) {
51
+ let cached;
52
+ return async () => {
53
+ if (cached && nowMs() - cached.fetchedAt < JWKS_TTL_MS)
54
+ return cached.jwks;
55
+ try {
56
+ const res = await fetchImpl(url);
57
+ if (!res.ok)
58
+ throw new Error(`JWKS fetch ${url} answered ${res.status}`);
59
+ const body = (await res.json());
60
+ if (!body || !Array.isArray(body.keys))
61
+ throw new Error(`JWKS document at ${url} has no keys`);
62
+ cached = { jwks: body, fetchedAt: nowMs() };
63
+ return body;
64
+ }
65
+ catch (err) {
66
+ if (cached)
67
+ return cached.jwks;
68
+ throw err;
69
+ }
70
+ };
71
+ }
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Seed data for the `list_items` multi-tenant example tool. `createdBy` and
3
- * `namespace` follow the tenant-id convention documented in tenancy.ts
4
- * (`scopeForTenant`) — each row is owned by, and namespaced under, exactly
5
- * one tenant.
2
+ * Seed data for the `list_items` multi-tenant example tool. Each row is stamped with the
3
+ * stored `orgId` of the organisation that owns it — the row is selected by comparing that
4
+ * stored id with the caller's verified organisation (tenancy.ts `isRowInTenant`), never by
5
+ * a creator name. `createdBy` / `namespace` stay as display fields only.
6
6
  */
7
7
  export interface ExampleItem {
8
8
  id: string;
9
9
  name: string;
10
+ orgId: string;
10
11
  createdBy: string;
11
12
  namespace: string;
12
13
  }
@@ -2,18 +2,21 @@ export const EXAMPLE_ITEMS = [
2
2
  {
3
3
  id: "item-1",
4
4
  name: "Tenant A widget",
5
+ orgId: "org_a",
5
6
  createdBy: "org_a",
6
7
  namespace: "org_a",
7
8
  },
8
9
  {
9
10
  id: "item-2",
10
11
  name: "Tenant A gadget",
12
+ orgId: "org_a",
11
13
  createdBy: "org_a",
12
14
  namespace: "org_a",
13
15
  },
14
16
  {
15
17
  id: "item-3",
16
18
  name: "Tenant B widget",
19
+ orgId: "org_b",
17
20
  createdBy: "org_b",
18
21
  namespace: "org_b",
19
22
  },
package/dist/http.js CHANGED
@@ -1,7 +1,17 @@
1
1
  import { loadAuthConfig, loadAuthMode } from "./config.js";
2
2
  import { buildHttpApp } from "./httpApp.js";
3
3
  const authMode = loadAuthMode();
4
- const app = buildHttpApp(authMode === "oauth" ? { authMode, auth: loadAuthConfig() } : { authMode });
4
+ const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
5
+ // OAuth tier: MCP_OAUTH_ISSUER + MCP_OAUTH_AUDIENCE select an EXTERNAL issuer (resource server
6
+ // only); otherwise this server is its own authorization server (ruling R1) and reads its
7
+ // settings from the environment per request — a missing value answers 503, see
8
+ // src/oauth/settings.ts.
9
+ const externalIssuer = Boolean(process.env.MCP_OAUTH_ISSUER && process.env.MCP_OAUTH_AUDIENCE);
10
+ const app = buildHttpApp(authMode === "oauth"
11
+ ? externalIssuer
12
+ ? { authMode, auth: loadAuthConfig(), uiDomain }
13
+ : { authMode, authorizationServer: {}, uiDomain }
14
+ : { authMode, uiDomain });
5
15
  const port = Number(process.env.PORT ?? 8787);
6
16
  // Hono's fetch handler adapted onto Node's http server (no extra framework
7
17
  // dependency beyond hono + node:http).