@growth-labs/mcp-wrapper-scaffold
v0.3.1
Published
Read-only MCP-wrapper scaffolding for Cloudflare Workers: bearer validation against a Secrets Store binding, a per-wrapper method+origin+path allowlist enforced via a closure passed into every tool handler, Google service-account JWT exchange, and an Expo
Readme
@growth-labs/mcp-wrapper-scaffold
Scaffolding for read-only MCP wrappers on Cloudflare Workers. Builds
the ExportedHandler that:
- speaks MCP JSON-RPC over HTTP (
initialize/tools/list/tools/call), - validates
Authorization: Bearer <token>against a Cloudflare Secrets Store binding using a constant-time comparison, - pre-binds a per-wrapper outbound allowlist (
origin+method+pathPattern) into a closure passed to every tool handler asctx.fetch, - exposes a Google service-account JWT-bearer helper
(
getGoogleAccessToken) that goes throughctx.fetch— no escape hatch, - optionally writes one
(tool, outcome, duration_ms)data point per request to a Cloudflare Analytics Engine binding.
tools/call returns the MCP CallToolResult shape:
{ content: [{ type: 'text', text: JSON.stringify(handlerResult) }] }.
Raw JSON-RPC clients should parse the first text content item; SDK clients
such as Codex expect this envelope. For rollout compatibility, object
handler results are also shallow-spread onto the result envelope, so existing
raw clients that read payload.result.rows keep working.
The closure is the gate. Wrapper code never sees a free gatedFetch
function — that's enforced at the API surface (this package does not
export it), at the lint layer (the consuming monorepo's Biome/ESLint
config forbids identifier fetch in wrapper sources), and at the bundle
layer (per-wrapper post-build grep). See the analytics-MCP design doc
(spec 2026-05-22) §4.4 for the full enforcement rationale.
Install
pnpm add @growth-labs/mcp-wrapper-scaffoldPeer runtime: Cloudflare Workers (Web Crypto + Fetch APIs).
Quick start
// apps/lemonsqueezy-mcp/src/index.ts
import {
createWrapperServer,
type AllowEntry,
type BaseEnv,
type SecretsStoreSecret,
type ToolDef,
} from '@growth-labs/mcp-wrapper-scaffold'
interface Env extends BaseEnv {
LS_API_KEY: SecretsStoreSecret
}
const ALLOWLIST: AllowEntry[] = [
{ origin: 'https://api.lemonsqueezy.com', method: 'GET', pathPattern: '/v1/*' },
]
const tools: ToolDef[] = [
{
name: 'list_orders',
description: 'List LemonSqueezy orders (read-only).',
inputSchema: { type: 'object', properties: {} },
async handler(_args, ctx) {
const apiKey = await (ctx.env as Env).LS_API_KEY.get()
const res = await ctx.fetch('https://api.lemonsqueezy.com/v1/orders', {
headers: { authorization: `Bearer ${apiKey}` },
})
return await res.json()
},
},
]
export default createWrapperServer({
tools,
agentTokenBinding: undefined as unknown as SecretsStoreSecret, // env.AGENT_TOKEN in real code
allowlist: ALLOWLIST,
})createWrapperServer returns an ExportedHandler<BaseEnv>. Because the
binding is captured from env at request time (not at module-init), the
agentTokenBinding argument is the binding object itself — pass
env.AGENT_TOKEN from inside a wrapper that initializes once per
isolate. The shape of apps/{wrapper}/src/index.ts in the wrappers
monorepo is:
let handler: ExportedHandler<Env> | undefined
export default {
fetch(request, env, ctx) {
if (!handler) {
handler = createWrapperServer({
tools,
agentTokenBinding: env.AGENT_TOKEN,
metricsBinding: env.MCP_METRICS,
allowlist: ALLOWLIST,
})
}
return handler.fetch!(request, env, ctx)
},
}(Captured once per isolate; the closure built inside createWrapperServer
— including the compiled regex set for the allowlist — is reused across
every request the isolate serves.)
Concepts
AllowEntry
interface AllowEntry {
origin: string // exact-string match against new URL(url).origin
method: 'GET' | 'HEAD' | 'POST'
pathPattern: string // glob with `*` = `[^/]*` (per-segment). `**` is rejected.
}Path patterns are anchored. * matches a single path segment. Regex
metacharacters in the rest of the pattern are escaped automatically.
Example allowlists from the design doc:
// LemonSqueezy (true GET-only):
const LS: AllowEntry[] = [
{ origin: 'https://api.lemonsqueezy.com', method: 'GET', pathPattern: '/v1/*' },
]
// GA4 (two data origins PLUS Google OAuth):
const GA4: AllowEntry[] = [
{ origin: 'https://oauth2.googleapis.com', method: 'POST', pathPattern: '/token' },
{ origin: 'https://analyticsdata.googleapis.com', method: 'POST', pathPattern: '/v1beta/properties/*:runReport' },
{ origin: 'https://analyticsdata.googleapis.com', method: 'POST', pathPattern: '/v1beta/properties/*:runRealtimeReport' },
{ origin: 'https://analyticsadmin.googleapis.com', method: 'GET', pathPattern: '/v1beta/accountSummaries' },
{ origin: 'https://analyticsadmin.googleapis.com', method: 'GET', pathPattern: '/v1beta/properties/*' },
]Any request whose (origin, method, path) triple doesn't match an
AllowEntry causes ctx.fetch to throw synchronously before any
network I/O — GatedFetchDenied, surfaced to the tool dispatcher as
JSON-RPC -32603. There's no recovery path inside the handler.
verifyAgentToken(authHeader, binding)
Constant-time bearer validation against a Secrets Store binding. Returns
false fail-closed on every error path (missing header, wrong scheme,
empty value, binding miss, byte mismatch).
You normally don't call this directly — createWrapperServer invokes it
on every request before dispatching to a tool. Re-exported for retrofit
scenarios (e.g. fulcrum-kb adopting the shared agent token without
rewriting its existing handler shape).
getGoogleAccessToken(saJson, scopes, ctxFetch)
Mints a Google access token via the service-account JWT-bearer flow.
Takes ctxFetch (the wrapper's allowlist closure) as the third
argument — there is no path to call oauth2.googleapis.com/token that
isn't gated by the wrapper's allowlist.
// Inside a GA4 tool handler:
const { access_token } = await getGoogleAccessToken(
await env.GA4_SA_JSON.get(),
['https://www.googleapis.com/auth/analytics.readonly'],
ctx.fetch,
)
const res = await ctx.fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${propertyId}:runReport`,
{
method: 'POST',
headers: { authorization: `Bearer ${access_token}`, 'content-type': 'application/json' },
body: JSON.stringify({ dateRanges: [...], dimensions: [...], metrics: [...] }),
},
)Tokens are cached per-isolate keyed by client_email + sorted scopes;
within expires_at - 60s repeat calls skip the exchange fetch. Cold
starts pay for one extra exchange.
If the wrapper's allowlist does NOT include
{ origin: 'https://oauth2.googleapis.com', method: 'POST', pathPattern: '/token' },
the call throws GatedFetchDenied synchronously. This is the §4.4
negative-contract test — the OAuth path is just another endpoint that
the allowlist must explicitly permit.
MCP_METRICS (optional)
If you declare an Analytics Engine binding named MCP_METRICS on the
worker, the scaffold writes one data point per tools/call:
blobs = [tool_name, outcome("ok" | "error")]
doubles = [duration_ms, timestamp_ms]
indexes = [tool_name]A throw inside writeDataPoint is logged and swallowed — metrics
emission never fails a request.
Why gatedFetch is not exported
If wrapper code could import a free gatedFetch, a single
import { gatedFetch } from '@growth-labs/mcp-wrapper-scaffold' followed
by gatedFetch(url, { allowlist: [...] }) would bypass the wrapper's
declared allowlist. By scoping the closure to createWrapperServer's
internals and passing it into handlers via ctx.fetch, the only
allowlist a tool can reach is the wrapper's own — declared at the
wrapper Worker's module boundary, not at the call site.
The __tests__/no-free-gated-fetch.test.ts test enumerates the
package's runtime exports and asserts the exact set. Adding a free
fetch-shape export would fail this test.
License
MIT (matches the rest of @growth-labs/*).
