@vantageos/mcp-boilerplate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/auth.d.ts +27 -0
- package/dist/auth.js +31 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +43 -0
- package/dist/exampleItems.d.ts +13 -0
- package/dist/exampleItems.js +20 -0
- package/dist/http.d.ts +1 -0
- package/dist/http.js +28 -0
- package/dist/httpApp.d.ts +52 -0
- package/dist/httpApp.js +113 -0
- package/dist/i18n.d.ts +33 -0
- package/dist/i18n.js +70 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/locales/en.json +13 -0
- package/dist/locales/fr.json +13 -0
- package/dist/mcpServerFactory.d.ts +60 -0
- package/dist/mcpServerFactory.js +180 -0
- package/dist/stdio.d.ts +8 -0
- package/dist/stdio.js +19 -0
- package/dist/tenancy.d.ts +49 -0
- package/dist/tenancy.js +58 -0
- package/dist/ui/uiResource.d.ts +34 -0
- package/dist/ui/uiResource.js +59 -0
- package/dist/ui-bundle/widget.html +61 -0
- package/examples/claude-desktop.json +8 -0
- package/package.json +86 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vantage-mcp-boilerplate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Clonable dual-transport MCP server template (stdio + Streamable HTTP, stateless) on the official @modelcontextprotocol/sdk, with protocol-kit result types, OAuth 2.1 auth + public tier, multi-tenant JWT scoping, MCP Apps UI extension, server-side FR/EN i18n, and a declarative eval harness. Scaffold, extend, publish.",
|
|
5
|
+
"description_fr": "Template de serveur MCP clonable a double transport (stdio + Streamable HTTP, stateless) sur le @modelcontextprotocol/sdk officiel, avec types de resultats protocol-kit, auth OAuth 2.1 + tier public, scoping multi-tenant par JWT, extension UI MCP Apps, i18n FR/EN cote serveur, et un harnais d'evaluations declaratif. Scaffolder, etendre, publier.",
|
|
6
|
+
"author": "ElPi Corp / Laurent Perello",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/elpiarthera/vantage-mcp-boilerplate",
|
|
9
|
+
"category": "mcp-server",
|
|
10
|
+
"bilingual": true,
|
|
11
|
+
"keywords": ["mcp", "claude-code", "boilerplate", "starter", "template", "stdio", "streamable-http", "oauth", "multi-tenant", "i18n", "vantage", "elpi"]
|
|
12
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ElPi Corp / Laurent Perello
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# @vantageos/mcp-boilerplate
|
|
2
|
+
|
|
3
|
+
A clonable MCP server skeleton: dual-transport bootstrap (stdio + Streamable
|
|
4
|
+
HTTP, stateless) built exclusively on the official
|
|
5
|
+
[`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk)
|
|
6
|
+
transports. No custom REST routes ever stand in front of a tool call.
|
|
7
|
+
|
|
8
|
+
This is a foundation, not a finished product: it ships one trivial example
|
|
9
|
+
tool (`ping`) whose only purpose is to prove the MCP handshake works on both
|
|
10
|
+
transports. Later layers (protocol-kit result types, auth, multi-tenant
|
|
11
|
+
scoping, UI extensions, i18n) build on top of this bootstrap without
|
|
12
|
+
changing the transport wiring.
|
|
13
|
+
|
|
14
|
+
## Structure
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
src/
|
|
18
|
+
mcpServerFactory.ts # single MCP server definition, shared by both transports
|
|
19
|
+
stdio.ts # official SDK StdioServerTransport entrypoint
|
|
20
|
+
http.ts # Node http.Server adapter around the Hono app
|
|
21
|
+
httpApp.ts # official SDK WebStandardStreamableHTTPServerTransport (stateless)
|
|
22
|
+
auth.ts # OAuth 2.1 resource-server wiring (@vantageos/mcp-auth-kit)
|
|
23
|
+
config.ts # env-derived AuthMode + AuthConfig loaders
|
|
24
|
+
index.ts # package exports
|
|
25
|
+
tests/
|
|
26
|
+
helpers/mcpRpc.ts # JSON-RPC request helpers for the HTTP transport
|
|
27
|
+
helpers/authFixtures.ts # test-only RS256 JWT signing fixtures
|
|
28
|
+
httpHandshake.test.ts # initialize -> tools/list -> tools/call over Streamable HTTP
|
|
29
|
+
stdioHandshake.test.ts # same handshake over a real spawned stdio child process
|
|
30
|
+
oauth.test.ts # both auth tiers: oauth gate + public no-auth tier
|
|
31
|
+
docs/
|
|
32
|
+
architecture.md # wire posture: tool-level vs wire-level protocol version
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install
|
|
39
|
+
npm run build
|
|
40
|
+
|
|
41
|
+
# stdio (local client config, e.g. Claude Code / Cursor) — never gated,
|
|
42
|
+
# the local process invoking stdio IS the caller.
|
|
43
|
+
npm run start:stdio
|
|
44
|
+
|
|
45
|
+
# Streamable HTTP (remote, stateless) — OAuth 2.1 gate by default:
|
|
46
|
+
MCP_OAUTH_ISSUER=https://your-tenant.example.com \
|
|
47
|
+
MCP_OAUTH_AUDIENCE=https://your-mcp-server.example.com \
|
|
48
|
+
PORT=8787 npm run start:http
|
|
49
|
+
|
|
50
|
+
# Streamable HTTP, no-auth "Public MCP" tier (local/self-host, trusted caller):
|
|
51
|
+
AUTH_MODE=public PORT=8787 npm run start:http
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Authentication tiers
|
|
55
|
+
|
|
56
|
+
Two tiers, per `mcp-standard.md` "Authentication & Authorization":
|
|
57
|
+
|
|
58
|
+
| `AUTH_MODE` | Behavior |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `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`. |
|
|
61
|
+
| `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). |
|
|
62
|
+
|
|
63
|
+
The stdio transport (`stdio.ts`) is never gated in either mode — the local
|
|
64
|
+
process invoking it IS the caller, per `mcp-standard.md`'s trust model for
|
|
65
|
+
local/self-host tools.
|
|
66
|
+
|
|
67
|
+
`buildHttpApp()` called with no arguments defaults to the `public` tier — this
|
|
68
|
+
is what the transport-skeleton tests (`httpHandshake.test.ts`,
|
|
69
|
+
`protocolKitWiring.test.ts`) exercise, so they need no OAuth configuration.
|
|
70
|
+
The production entrypoint (`http.ts`) always resolves `AUTH_MODE` from the
|
|
71
|
+
environment via `config.ts`, defaulting to `oauth` when unset.
|
|
72
|
+
|
|
73
|
+
## Server-side i18n (FR/EN error messages)
|
|
74
|
+
|
|
75
|
+
`src/i18n.ts` localizes MCP error/validation messages (auth rejection, tenant
|
|
76
|
+
refusal, tool input-validation) per `mcp-standard.md`'s bilingual mandate
|
|
77
|
+
(§Bilingual): the client sends `X-MCP-Locale: fr` or `X-MCP-Locale: en`
|
|
78
|
+
(default `en` when absent), and `src/httpApp.ts` + `src/mcpServerFactory.ts`
|
|
79
|
+
route the corresponding message through `t(locale, key)`.
|
|
80
|
+
|
|
81
|
+
The two resource files (`src/locales/{en,fr}.json`) follow the exact
|
|
82
|
+
namespaced-resource convention shipped by `@vantageos/mosaic-i18n@0.1.1`
|
|
83
|
+
(`node_modules/@vantageos/mosaic-i18n/src/locales/{en,fr}.json`): a flat
|
|
84
|
+
`<Namespace>.<category>.<key>` -> string object, FR and EN kept key-for-key
|
|
85
|
+
in lockstep. `mosaic-i18n` itself is a React/`react-i18next` package and
|
|
86
|
+
cannot be called directly from a JSON-RPC server, so its runtime API is not
|
|
87
|
+
imported here — its resource-file shape is reused instead, under this
|
|
88
|
+
boilerplate's own `Mcp` namespace.
|
|
89
|
+
|
|
90
|
+
## Testing
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm test
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Runs both transports against the real official-SDK code paths: the HTTP
|
|
97
|
+
suite drives the Hono app in-process; the stdio suite spawns the built
|
|
98
|
+
`dist/stdio.js` binary as a real child process via `StdioClientTransport`.
|
|
99
|
+
|
|
100
|
+
## Adding a tool
|
|
101
|
+
|
|
102
|
+
Register it once in `src/mcpServerFactory.ts` — it is then available on
|
|
103
|
+
both transports automatically. There is no separate REST route to add.
|
|
104
|
+
|
|
105
|
+
## Packaging & distribution
|
|
106
|
+
|
|
107
|
+
This boilerplate is itself a publishable/clonable npm package. Two artifacts
|
|
108
|
+
carry its distribution identity:
|
|
109
|
+
|
|
110
|
+
- `package.json` — the npm package. `files` ships only the built `dist/` tree
|
|
111
|
+
(compiled server + `dist/locales/` + `dist/ui-bundle/widget.html`), the
|
|
112
|
+
`README.md`, the `LICENSE`, the plugin manifest, and the example client
|
|
113
|
+
config — never `src/`, tests, or tooling. `main`/`types`/`exports` point at
|
|
114
|
+
`dist/index.js`; the `mcp-boilerplate` bin points at `dist/stdio.js`.
|
|
115
|
+
`publishConfig.access = public` is required for the `@vantageos` scope.
|
|
116
|
+
- `.claude-plugin/plugin.json` — the Claude Code marketplace / VantageRegistry
|
|
117
|
+
catalogue manifest, following the fleet MCP-server convention (mirrors
|
|
118
|
+
`vantage-frameworks-mcp` / `vantage-agent-composer-mcp`): `name`, `version`,
|
|
119
|
+
bilingual `description` + `description_fr`, `author`, `license`, `homepage`,
|
|
120
|
+
`category: "mcp-server"`, `bilingual: true`, `keywords`.
|
|
121
|
+
|
|
122
|
+
### Scaffold → build → publish
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# 1. Scaffold: clone this repo (or the npm tarball) as your server's skeleton.
|
|
126
|
+
git clone https://github.com/elpiarthera/vantage-mcp-boilerplate.git my-mcp
|
|
127
|
+
cd my-mcp && npm install --legacy-peer-deps # see dev peer-range note above
|
|
128
|
+
|
|
129
|
+
# 2. Rename: set package.json "name"/"bin" and .claude-plugin/plugin.json
|
|
130
|
+
# "name" to your server, then add tools in src/mcpServerFactory.ts.
|
|
131
|
+
|
|
132
|
+
# 3. Build + verify from a clean tree (what CI and prepublishOnly enforce):
|
|
133
|
+
rm -rf dist node_modules/.vite && npm run build && npm test && npm run evals
|
|
134
|
+
|
|
135
|
+
# 4. Inspect the tarball before publishing — confirm dist/ ships and nothing leaks:
|
|
136
|
+
npm pack --dry-run
|
|
137
|
+
|
|
138
|
+
# 5. Publish to the two channels:
|
|
139
|
+
npm publish # npm registry (prepublishOnly re-runs build+test+evals)
|
|
140
|
+
# VantageRegistry catalogue: register .claude-plugin/plugin.json via
|
|
141
|
+
# the fleet create-plugin / generate-marketplace skill (VR upsert).
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`prepublishOnly` re-runs `build → build:ui → test → evals` so a broken or
|
|
145
|
+
src-only tarball can never reach npm. The distribution channels are **npm**
|
|
146
|
+
(the runnable package) and the **VantageRegistry / Claude Code marketplace
|
|
147
|
+
catalogue** (discovery, via `plugin.json`).
|
|
148
|
+
|
|
149
|
+
## MCP Apps UI layer
|
|
150
|
+
|
|
151
|
+
`render_items_ui` renders `EXAMPLE_ITEMS` as an interactive widget via the
|
|
152
|
+
official `@modelcontextprotocol/ext-apps/server` (`registerAppTool` +
|
|
153
|
+
`registerAppResource`), GA `_meta.ui.resourceUri` nested form (stable
|
|
154
|
+
2026-01-26) — never the deprecated flat `_meta["ui/resourceUri"]` key. A
|
|
155
|
+
markdown fallback is always present in `content[0]`, even for clients that
|
|
156
|
+
never negotiate the `io.modelcontextprotocol/ui` extension.
|
|
157
|
+
|
|
158
|
+
The widget (`src/ui/`) reuses `@vantageos/mosaic-blocks`' `MosaicCard`
|
|
159
|
+
family and is bundled to a single self-contained HTML file
|
|
160
|
+
(`vite-plugin-singlefile`, `dist/ui-bundle/widget.html`, ~70 KB gzipped, no
|
|
161
|
+
external runtime fetch). `npm run build:ui` produces this bundle
|
|
162
|
+
independently of `npm run build` (tsc), on a non-colliding output path
|
|
163
|
+
(`dist/ui-bundle/` vs tsc's `dist/ui/`) — the two build steps write to the
|
|
164
|
+
same-looking `dist/ui*` tree but never share a directory, so
|
|
165
|
+
`emptyOutDir` on the vite side can never delete a tsc-compiled server
|
|
166
|
+
module.
|
|
167
|
+
|
|
168
|
+
**Known dev peer-range skew**: `@vantageos/mosaic-blocks@0.5.34-alpha`
|
|
169
|
+
declares `peerOptional @vantageos/cloud-identity@^0.2`, while this
|
|
170
|
+
boilerplate's own dependency is `^0.3.0` (via `@vantageos/mcp-auth-kit`).
|
|
171
|
+
`npm install` currently needs `--legacy-peer-deps` to resolve. This is a
|
|
172
|
+
dev-only peer-range mismatch (mosaic-blocks is a devDependency, used only
|
|
173
|
+
to build the widget bundle) — the full test suite is green from a clean
|
|
174
|
+
build under the `^0.3.0` runtime dependency, proving no runtime conflict.
|
|
175
|
+
|
|
176
|
+
**Publish ordering (hard requirement)**: this boilerplate depends on
|
|
177
|
+
`@vantageos/mcp-protocol-kit@^0.2.0`. The `tools/list` wiring uses the kit's
|
|
178
|
+
`buildToolsListResult`, which exists ONLY in kit 0.2.0+. In 0.x semver `^0.2.0`
|
|
179
|
+
does NOT accept 0.1.x, so a fresh `npm install` from the registry resolves the
|
|
180
|
+
kit build the code actually needs. Therefore **kit `@vantageos/mcp-protocol-kit`
|
|
181
|
+
0.2.0 MUST be published to npm BEFORE this boilerplate can be installed or
|
|
182
|
+
published from the registry.** This is a strict ordering, not optional: publish
|
|
183
|
+
the kit first, confirm 0.2.0 is live at the registry, then the boilerplate.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 resource-server wiring for this MCP server's HTTP transport.
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* This is the "OAuth 2.1" tier of the two auth tiers this boilerplate
|
|
11
|
+
* supports (see config.ts `AuthMode`). The other tier — "public" — never
|
|
12
|
+
* touches this module at all: the HTTP gate is skipped entirely, and the
|
|
13
|
+
* stdio transport (stdio.ts) never had a gate to begin with (local process
|
|
14
|
+
* trust model, per mcp-standard.md).
|
|
15
|
+
*/
|
|
16
|
+
import { type McpResourceServerResult } from "@vantageos/mcp-auth-kit";
|
|
17
|
+
import type { JWTVerifyGetKey } from "jose";
|
|
18
|
+
export interface AuthConfig {
|
|
19
|
+
realm: string;
|
|
20
|
+
resourceMetadataUrl: string;
|
|
21
|
+
jwks: JWTVerifyGetKey;
|
|
22
|
+
issuer: string;
|
|
23
|
+
audience: string;
|
|
24
|
+
authorizationServers: string[];
|
|
25
|
+
}
|
|
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;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 resource-server wiring for this MCP server's HTTP transport.
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* This is the "OAuth 2.1" tier of the two auth tiers this boilerplate
|
|
11
|
+
* supports (see config.ts `AuthMode`). The other tier — "public" — never
|
|
12
|
+
* touches this module at all: the HTTP gate is skipped entirely, and the
|
|
13
|
+
* stdio transport (stdio.ts) never had a gate to begin with (local process
|
|
14
|
+
* trust model, per mcp-standard.md).
|
|
15
|
+
*/
|
|
16
|
+
import { buildProtectedResourceMetadata, verifyMcpBearerRequest, } from "@vantageos/mcp-auth-kit";
|
|
17
|
+
export async function authenticateRequest(authHeader, config) {
|
|
18
|
+
return verifyMcpBearerRequest(authHeader, {
|
|
19
|
+
realm: config.realm,
|
|
20
|
+
resourceMetadataUrl: config.resourceMetadataUrl,
|
|
21
|
+
jwks: config.jwks,
|
|
22
|
+
issuer: config.issuer,
|
|
23
|
+
audience: config.audience,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function protectedResourceMetadataDocument(config) {
|
|
27
|
+
return buildProtectedResourceMetadata({
|
|
28
|
+
resource: config.resourceMetadataUrl.replace(/\/\.well-known\/.*/, ""),
|
|
29
|
+
authorizationServers: config.authorizationServers,
|
|
30
|
+
});
|
|
31
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-derived configuration for the two auth tiers this
|
|
3
|
+
* boilerplate ships (mcp-standard.md "Authentication & Authorization"):
|
|
4
|
+
*
|
|
5
|
+
* - "oauth" (default): OAuth 2.1 resource server on the HTTP transport —
|
|
6
|
+
* every /mcp call requires a valid RS256 JWT, audience/issuer-bound.
|
|
7
|
+
* - "public": no gate at all on the HTTP transport — for local/self-host
|
|
8
|
+
* use where the caller is already trusted (mirrors the stdio transport's
|
|
9
|
+
* trust model, which never had a gate). Chosen explicitly, never a
|
|
10
|
+
* silent fallback from a missing env var.
|
|
11
|
+
*
|
|
12
|
+
* No hardcoded business values — every deployment-specific identity (base
|
|
13
|
+
* URL, issuer, audience) comes from the environment, never a literal in
|
|
14
|
+
* code.
|
|
15
|
+
*/
|
|
16
|
+
import { type JWTVerifyGetKey, createLocalJWKSet } from "jose";
|
|
17
|
+
import type { AuthConfig } from "./auth.js";
|
|
18
|
+
export type AuthMode = "oauth" | "public";
|
|
19
|
+
export declare function loadAuthMode(env?: NodeJS.ProcessEnv): AuthMode;
|
|
20
|
+
export declare function loadAuthConfig(env?: NodeJS.ProcessEnv, jwksOverride?: JWTVerifyGetKey): AuthConfig;
|
|
21
|
+
export { createLocalJWKSet };
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-derived configuration for the two auth tiers this
|
|
3
|
+
* boilerplate ships (mcp-standard.md "Authentication & Authorization"):
|
|
4
|
+
*
|
|
5
|
+
* - "oauth" (default): OAuth 2.1 resource server on the HTTP transport —
|
|
6
|
+
* every /mcp call requires a valid RS256 JWT, audience/issuer-bound.
|
|
7
|
+
* - "public": no gate at all on the HTTP transport — for local/self-host
|
|
8
|
+
* use where the caller is already trusted (mirrors the stdio transport's
|
|
9
|
+
* trust model, which never had a gate). Chosen explicitly, never a
|
|
10
|
+
* silent fallback from a missing env var.
|
|
11
|
+
*
|
|
12
|
+
* No hardcoded business values — every deployment-specific identity (base
|
|
13
|
+
* URL, issuer, audience) comes from the environment, never a literal in
|
|
14
|
+
* code.
|
|
15
|
+
*/
|
|
16
|
+
import { createLocalJWKSet, createRemoteJWKSet } from "jose";
|
|
17
|
+
export function loadAuthMode(env = process.env) {
|
|
18
|
+
const raw = (env.AUTH_MODE ?? "oauth").toLowerCase();
|
|
19
|
+
if (raw !== "oauth" && raw !== "public") {
|
|
20
|
+
throw new Error(`AUTH_MODE must be "oauth" or "public", got "${raw}". Set AUTH_MODE=public for the no-auth local/self-host tier, or leave unset for the OAuth 2.1 default.`);
|
|
21
|
+
}
|
|
22
|
+
return raw;
|
|
23
|
+
}
|
|
24
|
+
export function loadAuthConfig(env = process.env, jwksOverride) {
|
|
25
|
+
const issuer = env.MCP_OAUTH_ISSUER;
|
|
26
|
+
const audience = env.MCP_OAUTH_AUDIENCE;
|
|
27
|
+
const publicBaseUrl = env.PUBLIC_BASE_URL ?? "http://localhost:8787";
|
|
28
|
+
if (!issuer || !audience) {
|
|
29
|
+
throw new Error("MCP_OAUTH_ISSUER and MCP_OAUTH_AUDIENCE are required to run the HTTP transport in " +
|
|
30
|
+
"oauth mode (OAuth 2.1 resource-server requirement — mcp-standard.md Critical Rule #14). " +
|
|
31
|
+
"Set AUTH_MODE=public instead to run the no-auth local/self-host tier.");
|
|
32
|
+
}
|
|
33
|
+
const jwks = jwksOverride ?? createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
|
|
34
|
+
return {
|
|
35
|
+
realm: "mcp",
|
|
36
|
+
resourceMetadataUrl: `${publicBaseUrl}/.well-known/oauth-protected-resource`,
|
|
37
|
+
jwks,
|
|
38
|
+
issuer,
|
|
39
|
+
audience,
|
|
40
|
+
authorizationServers: [issuer],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export { createLocalJWKSet };
|
|
@@ -0,0 +1,13 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
export interface ExampleItem {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
createdBy: string;
|
|
11
|
+
namespace: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const EXAMPLE_ITEMS: ExampleItem[];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const EXAMPLE_ITEMS = [
|
|
2
|
+
{
|
|
3
|
+
id: "item-1",
|
|
4
|
+
name: "Tenant A widget",
|
|
5
|
+
createdBy: "org_a",
|
|
6
|
+
namespace: "org_a",
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
id: "item-2",
|
|
10
|
+
name: "Tenant A gadget",
|
|
11
|
+
createdBy: "org_a",
|
|
12
|
+
namespace: "org_a",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "item-3",
|
|
16
|
+
name: "Tenant B widget",
|
|
17
|
+
createdBy: "org_b",
|
|
18
|
+
namespace: "org_b",
|
|
19
|
+
},
|
|
20
|
+
];
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { loadAuthConfig, loadAuthMode } from "./config.js";
|
|
2
|
+
import { buildHttpApp } from "./httpApp.js";
|
|
3
|
+
const authMode = loadAuthMode();
|
|
4
|
+
const app = buildHttpApp(authMode === "oauth" ? { authMode, auth: loadAuthConfig() } : { authMode });
|
|
5
|
+
const port = Number(process.env.PORT ?? 8787);
|
|
6
|
+
// Hono's fetch handler adapted onto Node's http server (no extra framework
|
|
7
|
+
// dependency beyond hono + node:http).
|
|
8
|
+
const { createServer } = await import("node:http");
|
|
9
|
+
createServer(async (req, res) => {
|
|
10
|
+
const url = `http://${req.headers.host ?? "localhost"}${req.url}`;
|
|
11
|
+
const chunks = [];
|
|
12
|
+
for await (const chunk of req)
|
|
13
|
+
chunks.push(chunk);
|
|
14
|
+
const body = req.method === "GET" || req.method === "HEAD" ? undefined : Buffer.concat(chunks);
|
|
15
|
+
const headers = new Headers();
|
|
16
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
17
|
+
if (typeof value === "string")
|
|
18
|
+
headers.set(key, value);
|
|
19
|
+
}
|
|
20
|
+
const request = new Request(url, { method: req.method, headers, body });
|
|
21
|
+
const response = await app.fetch(request);
|
|
22
|
+
res.statusCode = response.status;
|
|
23
|
+
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
24
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
25
|
+
res.end(buf);
|
|
26
|
+
}).listen(port, () => {
|
|
27
|
+
console.log(`mcp-boilerplate HTTP transport listening on :${port} (authMode: ${authMode})`);
|
|
28
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono HTTP app: stateless MCP transport for the boilerplate skeleton.
|
|
3
|
+
*
|
|
4
|
+
* The MCP wire protocol itself is served via the official
|
|
5
|
+
* `@modelcontextprotocol/sdk` `WebStandardStreamableHTTPServerTransport`
|
|
6
|
+
* (stateless mode: `sessionIdGenerator: undefined`) — a standard MCP client
|
|
7
|
+
* (Claude Code, ChatGPT, Cursor) speaks JSON-RPC over this endpoint exactly
|
|
8
|
+
* as it would over any other MCP HTTP server. No ad-hoc REST routes stand
|
|
9
|
+
* in front of tool calls.
|
|
10
|
+
*
|
|
11
|
+
* True statelessness: a FRESH `McpServer` + a FRESH stateless transport are
|
|
12
|
+
* created for every single POST to /mcp, connected, used to handle exactly
|
|
13
|
+
* one request, and never retained. Nothing survives past the request/
|
|
14
|
+
* response cycle, so the endpoint is horizontally load-balanceable and two
|
|
15
|
+
* concurrent requests never share any in-memory object.
|
|
16
|
+
*
|
|
17
|
+
* `server/discover` is NOT a separate REST route: it is a JSON-RPC method
|
|
18
|
+
* on this same `/mcp` endpoint (registered on the low-level `Server` in
|
|
19
|
+
* mcpServerFactory.ts via `@vantageos/mcp-protocol-kit`'s
|
|
20
|
+
* `handleServerDiscover`), exactly like `tools/list` and `tools/call` —
|
|
21
|
+
* a standard MCP client dispatches all three the same way.
|
|
22
|
+
*
|
|
23
|
+
* Auth tiers (mcp-standard.md "Authentication & Authorization"):
|
|
24
|
+
*
|
|
25
|
+
* - `authMode: "oauth"` — OAuth 2.1 resource server via
|
|
26
|
+
* @vantageos/mcp-auth-kit. Every /mcp call requires a valid Bearer JWT
|
|
27
|
+
* (RS256, audience/issuer-bound); the RFC 9728 Protected Resource
|
|
28
|
+
* Metadata document is served at `/.well-known/oauth-protected-resource`;
|
|
29
|
+
* `/health` stays token-free. Requires an `AuthConfig` (deps.auth) — see
|
|
30
|
+
* config.ts `loadAuthConfig()`.
|
|
31
|
+
* - `authMode: "public"` — no gate at all. For local/self-host use where
|
|
32
|
+
* the caller is already trusted, mirroring the stdio transport's trust
|
|
33
|
+
* model (stdio.ts never had a gate to begin with). This is the DEFAULT
|
|
34
|
+
* when `buildHttpApp()` is called with no deps, so the transport-only
|
|
35
|
+
* tests (httpHandshake.test.ts, protocolKitWiring.test.ts) exercise the
|
|
36
|
+
* skeleton without needing OAuth configuration.
|
|
37
|
+
*/
|
|
38
|
+
import { Hono } from "hono";
|
|
39
|
+
import { type AuthConfig } from "./auth.js";
|
|
40
|
+
import type { AuthMode } from "./config.js";
|
|
41
|
+
import { type ResolvedTenant } from "./tenancy.js";
|
|
42
|
+
export interface AppDeps {
|
|
43
|
+
authMode?: AuthMode;
|
|
44
|
+
auth?: AuthConfig;
|
|
45
|
+
}
|
|
46
|
+
type Variables = {
|
|
47
|
+
tenant?: ResolvedTenant;
|
|
48
|
+
};
|
|
49
|
+
export declare function buildHttpApp(deps?: AppDeps): Hono<{
|
|
50
|
+
Variables: Variables;
|
|
51
|
+
}>;
|
|
52
|
+
export {};
|
package/dist/httpApp.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono HTTP app: stateless MCP transport for the boilerplate skeleton.
|
|
3
|
+
*
|
|
4
|
+
* The MCP wire protocol itself is served via the official
|
|
5
|
+
* `@modelcontextprotocol/sdk` `WebStandardStreamableHTTPServerTransport`
|
|
6
|
+
* (stateless mode: `sessionIdGenerator: undefined`) — a standard MCP client
|
|
7
|
+
* (Claude Code, ChatGPT, Cursor) speaks JSON-RPC over this endpoint exactly
|
|
8
|
+
* as it would over any other MCP HTTP server. No ad-hoc REST routes stand
|
|
9
|
+
* in front of tool calls.
|
|
10
|
+
*
|
|
11
|
+
* True statelessness: a FRESH `McpServer` + a FRESH stateless transport are
|
|
12
|
+
* created for every single POST to /mcp, connected, used to handle exactly
|
|
13
|
+
* one request, and never retained. Nothing survives past the request/
|
|
14
|
+
* response cycle, so the endpoint is horizontally load-balanceable and two
|
|
15
|
+
* concurrent requests never share any in-memory object.
|
|
16
|
+
*
|
|
17
|
+
* `server/discover` is NOT a separate REST route: it is a JSON-RPC method
|
|
18
|
+
* on this same `/mcp` endpoint (registered on the low-level `Server` in
|
|
19
|
+
* mcpServerFactory.ts via `@vantageos/mcp-protocol-kit`'s
|
|
20
|
+
* `handleServerDiscover`), exactly like `tools/list` and `tools/call` —
|
|
21
|
+
* a standard MCP client dispatches all three the same way.
|
|
22
|
+
*
|
|
23
|
+
* Auth tiers (mcp-standard.md "Authentication & Authorization"):
|
|
24
|
+
*
|
|
25
|
+
* - `authMode: "oauth"` — OAuth 2.1 resource server via
|
|
26
|
+
* @vantageos/mcp-auth-kit. Every /mcp call requires a valid Bearer JWT
|
|
27
|
+
* (RS256, audience/issuer-bound); the RFC 9728 Protected Resource
|
|
28
|
+
* Metadata document is served at `/.well-known/oauth-protected-resource`;
|
|
29
|
+
* `/health` stays token-free. Requires an `AuthConfig` (deps.auth) — see
|
|
30
|
+
* config.ts `loadAuthConfig()`.
|
|
31
|
+
* - `authMode: "public"` — no gate at all. For local/self-host use where
|
|
32
|
+
* the caller is already trusted, mirroring the stdio transport's trust
|
|
33
|
+
* model (stdio.ts never had a gate to begin with). This is the DEFAULT
|
|
34
|
+
* when `buildHttpApp()` is called with no deps, so the transport-only
|
|
35
|
+
* tests (httpHandshake.test.ts, protocolKitWiring.test.ts) exercise the
|
|
36
|
+
* skeleton without needing OAuth configuration.
|
|
37
|
+
*/
|
|
38
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
39
|
+
import { Hono } from "hono";
|
|
40
|
+
import { authenticateRequest, protectedResourceMetadataDocument } from "./auth.js";
|
|
41
|
+
import { detectLocale, t } from "./i18n.js";
|
|
42
|
+
import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
|
|
43
|
+
import { resolveTenantFromJwtPayload } from "./tenancy.js";
|
|
44
|
+
const WELL_KNOWN_PATHS = ["/.well-known/oauth-protected-resource", "/health"];
|
|
45
|
+
export function buildHttpApp(deps = {}) {
|
|
46
|
+
const app = new Hono();
|
|
47
|
+
const authMode = deps.authMode ?? "public";
|
|
48
|
+
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
49
|
+
if (authMode === "oauth") {
|
|
50
|
+
if (!deps.auth) {
|
|
51
|
+
throw new Error("authMode 'oauth' requires an AuthConfig (deps.auth) — build one via " +
|
|
52
|
+
"config.ts loadAuthConfig(), or pass authMode: 'public' for the no-auth tier.");
|
|
53
|
+
}
|
|
54
|
+
const auth = deps.auth;
|
|
55
|
+
app.get("/.well-known/oauth-protected-resource", (c) => {
|
|
56
|
+
return c.json(protectedResourceMetadataDocument(auth));
|
|
57
|
+
});
|
|
58
|
+
// OAuth 2.1 resource-server gate — every route below this line requires
|
|
59
|
+
// a valid Bearer JWT, verified entirely by @vantageos/mcp-auth-kit.
|
|
60
|
+
app.use("*", async (c, next) => {
|
|
61
|
+
if (WELL_KNOWN_PATHS.includes(c.req.path)) {
|
|
62
|
+
await next();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const authHeader = c.req.header("Authorization");
|
|
66
|
+
const result = await authenticateRequest(authHeader, auth);
|
|
67
|
+
if (!result.ok) {
|
|
68
|
+
const locale = detectLocale(c.req.header("X-MCP-Locale"));
|
|
69
|
+
c.header("WWW-Authenticate", result.headers["WWW-Authenticate"]);
|
|
70
|
+
return c.json({ error: t(locale, "auth.unauthorized") }, result.status);
|
|
71
|
+
}
|
|
72
|
+
// Tenant resolution (tenancy.ts) is attempted here but is NOT
|
|
73
|
+
// request-fatal: `initialize`/`tools/list` and any tenant-agnostic
|
|
74
|
+
// tool (e.g. `ping`) must keep working for a verified caller that has
|
|
75
|
+
// no organization on their token. Tenant-scoped tools (`list_items`)
|
|
76
|
+
// enforce the requirement themselves, at the tool-call level (see
|
|
77
|
+
// mcpServerFactory.ts) — a missing tenant there is refused as an MCP
|
|
78
|
+
// tool error, never silently treated as an unscoped/master caller.
|
|
79
|
+
try {
|
|
80
|
+
c.set("tenant", resolveTenantFromJwtPayload(result.payload));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// No organization on this token — tenant stays unset. Tenant-scoped
|
|
84
|
+
// tools refuse explicitly when they see no tenant under oauth mode.
|
|
85
|
+
}
|
|
86
|
+
await next();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// The one real MCP endpoint: standard JSON-RPC over Streamable HTTP,
|
|
90
|
+
// stateless mode. GET/POST/DELETE are all delegated to the SDK transport,
|
|
91
|
+
// which handles the protocol's method dispatch itself.
|
|
92
|
+
app.all("/mcp", async (c) => {
|
|
93
|
+
const tenant = authMode === "oauth" ? c.get("tenant") : undefined;
|
|
94
|
+
const locale = detectLocale(c.req.header("X-MCP-Locale"));
|
|
95
|
+
const server = createBoilerplateMcpServer({
|
|
96
|
+
tenant,
|
|
97
|
+
requireTenant: authMode === "oauth",
|
|
98
|
+
locale,
|
|
99
|
+
});
|
|
100
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
101
|
+
sessionIdGenerator: undefined, // stateless mode — no session ID anywhere
|
|
102
|
+
enableJsonResponse: true,
|
|
103
|
+
});
|
|
104
|
+
await server.connect(transport);
|
|
105
|
+
try {
|
|
106
|
+
return await transport.handleRequest(c.req.raw);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await transport.close();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return app;
|
|
113
|
+
}
|
package/dist/i18n.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin server-side locale layer for MCP error/validation messages.
|
|
3
|
+
*
|
|
4
|
+
* REUSE: @vantageos/mosaic-i18n@0.1.1 is a React/react-i18next package
|
|
5
|
+
* (`peerDependencies: i18next, react-i18next`) — its runtime API targets a
|
|
6
|
+
* React component tree, not a JSON-RPC server, so it cannot be called
|
|
7
|
+
* directly from tool handlers here. What IS reused, verified firsthand in
|
|
8
|
+
* `node_modules/@vantageos/mosaic-i18n/src/locales/{en,fr}.json`, is its
|
|
9
|
+
* resource-file CONVENTION: a flat namespaced object
|
|
10
|
+
* (`<Component>.<category>.<key>` -> string), one file per locale, FR and
|
|
11
|
+
* EN kept in lockstep key-for-key. `src/locales/{en,fr}.json` in this
|
|
12
|
+
* boilerplate follows that exact shape under a single `Mcp` namespace
|
|
13
|
+
* (`Mcp.auth.*`, `Mcp.tenant.*`, `Mcp.validation.*`), so the two locale
|
|
14
|
+
* files could be merged into mosaic-i18n's own resource files verbatim if
|
|
15
|
+
* this boilerplate ever grows a React UI consumer of the same strings.
|
|
16
|
+
*
|
|
17
|
+
* Locale detection follows mcp-standard.md's `X-MCP-Locale` request header
|
|
18
|
+
* (§Bilingual, l.234): "fr" selects the French resource, anything else
|
|
19
|
+
* (including a missing header) defaults to English.
|
|
20
|
+
*/
|
|
21
|
+
export type McpLocale = "en" | "fr";
|
|
22
|
+
/**
|
|
23
|
+
* Resolves the caller's locale from the `X-MCP-Locale` request header.
|
|
24
|
+
* Defaults to "en" for a missing header or any value other than "fr".
|
|
25
|
+
*/
|
|
26
|
+
export declare function detectLocale(headerValue: string | null | undefined): McpLocale;
|
|
27
|
+
/**
|
|
28
|
+
* Looks up a translated MCP message. `key` is a dot path under the `Mcp`
|
|
29
|
+
* namespace, e.g. `"auth.unauthorized"` or `"tenant.permissionDenied"`.
|
|
30
|
+
* Falls back to the English string (never to the raw key) if the requested
|
|
31
|
+
* locale is somehow missing a key the English resource carries.
|
|
32
|
+
*/
|
|
33
|
+
export declare function t(locale: McpLocale, key: string): string;
|