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

@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 as ctx.fetch,
  • exposes a Google service-account JWT-bearer helper (getGoogleAccessToken) that goes through ctx.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-scaffold

Peer 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/*).