{"slug": "show-hn-static-mcp-publish-mcp-tools-as-static-files-run-them-sandboxed", "title": "Show HN: Static MCP – publish MCP tools as static files, run them sandboxed", "summary": "Static MCP, a new open-source project, lets developers publish MCP tools as static, hash-verified files executed sandboxed on demand, eliminating the need for server infrastructure. The reference implementation, mcpwasm, runs untrusted tool code inside QuickJS-wasm on Cloudflare Workers and integrates with the llms-txt-skills standard. Users can run tools from any static site via a single npx command, with no account or deploy required.", "body_md": "**Static MCP: your tools are files, not servers.** Tools are published as\nstatic, hash-verified content and executed sandboxed on demand. What static\nsite hosting did to web servers — \"don't run Apache, publish HTML\" — this does\nto MCP servers: don't run an MCP server, publish files. The publisher needs\nzero infrastructure; the MCP server is materialized per request from those\nfiles and evaporates after responding (ephemeral instance, durable definition).\n\nmcpwasm is the reference implementation: a sandboxed runtime for third-party\nMCP tools (untrusted tool code inside QuickJS-wasm on Cloudflare Workers),\nplus a gateway that turns any static site publishing `llms.txt`\n\nwith\nexecutable skills into an MCP server.\n\nThink \"php-wasm, but for MCP tools\": the platform owner embeds the host, loads\n`tool.js`\n\nfiles, and each tool runs isolated in a QuickJS WebAssembly sandbox.\nThe only bridge from the sandbox to the platform's internals is an explicit\ncapability the host injects. No capability, no access.\n\nThis repo integrates with the [llms-txt-skills](https://github.com/MauricioPerera/llms-txt-skills)\nstandard via two provisional extensions adopted in the spec: **executable\nskills** (v0.4, with *origin memory*) and **skill attestations** (v0.4). See\nthe dedicated sections below.\n\nPoint the local runtime at any origin that publishes `llms.txt`\n\nwith executable\nskills. It fetches `/llms.txt`\n\n, verifies every `tool_sha256`\n\n, sandboxes each\nskill in QuickJS-wasm, and speaks MCP over stdio — **no account, no deploy, no\ninfrastructure on either side**:\n\n```\nnpx -y @rckflr/mcpwasm https://usuario.github.io\n```\n\nWire it into an MCP client (Claude Code, Cursor, Cline, …):\n\n```\n{\n  \"mcpServers\": {\n    \"misitio\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@rckflr/mcpwasm\", \"https://usuario.github.io\"]\n    }\n  }\n}\n```\n\nThat is the whole path from a static site to a running MCP server. The two\nother ways to use mcpwasm are for when you specifically need them: the **hosted\ngateway** (multi-tenant, on Cloudflare Workers — serves many origins behind one\nendpoint, with an onboarding step per origin) and the **embeddable library**\n(`@rckflr/mcpwasm`\n\n— for building your own host). Both are documented below;\nthe local runtime above needs none of it. Its honest limits and the\n`index.json`\n\n/attestation options are detailed under\n\"[Local MCP runtime](#local-mcp-runtime--no-gateway-at-all)\".\n\nThe runtime is a standard MCP stdio server, so every MCP host can use it.\nReplace the origin with any publisher (find more in the\n[registry](https://github.com/MauricioPerera/llms-skills-registry)); add\n`--lock skills.lock`\n\nfor pin-on-first-use.\n\n**Claude Code** (one command):\n\n```\nclaude mcp add static-skills -- npx -y @rckflr/mcpwasm https://mauricioperera.github.io\n```\n\n**Claude Desktop** (`claude_desktop_config.json`\n\n) / **Cursor**\n(`.cursor/mcp.json`\n\n) / **project-scoped .mcp.json** — same JSON shape:\n\n```\n{\n  \"mcpServers\": {\n    \"static-skills\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@rckflr/mcpwasm\", \"https://mauricioperera.github.io\", \"--lock\", \"skills.lock\"]\n    }\n  }\n}\n```\n\nRestart the session and the origin's verified tools (plus their SKILL.md recipes as resources) appear like any other MCP server's.\n\nThe embeddable host — what the gateway itself builds on — ships as\n[ @rckflr/mcpwasm](https://www.npmjs.com/package/@rckflr/mcpwasm):\n\n```\nnpm install @rckflr/mcpwasm\njs\nimport { AsyncToolHost } from \"@rckflr/mcpwasm\";\n\nconst host = new AsyncToolHost({ allowedOrigin: \"https://example.com\" });\nawait host.init();\nhost.loadToolSource(toolJsSource); // a tool.js that calls registerTool({...})\nconst tools = host.listTools();\nconst result = await host.callTool(\"sum_numbers\", { a: 2, b: 40 });\nhost.dispose();\n```\n\nNotes:\n\n- In Cloudflare Workers, pass a pre-built asyncify module via the\n`quickjs`\n\noption (see`worker-gateway.mjs`\n\nfor the`CompiledWasm`\n\nimport pattern and import`@rckflr/mcpwasm/shim`\n\nfirst). - Subpath exports:\n`/host`\n\n(sync`ToolHost`\n\n),`/host-async`\n\n,`/mcp-core`\n\n,`/mcp-core-async`\n\n,`/llmstxt-parse`\n\n,`/shim`\n\n. - The sync\n`ToolHost`\n\nlazy-imports the optional peer`quickjs-emscripten`\n\nunless you pass a pre-built module; the async host's dependencies install with the package. - The package contains only the host/core/parser files plus the local runtime binary; the workers, publisher sites and test suites stay in this repo (they are the deployed reference, not the library).\n\nThe package also ships a stdio MCP server that runs an origin's skills\n**locally**: it fetches `/llms.txt`\n\n, verifies every `tool_sha256`\n\n, loads each\nverified skill into its own QuickJS-wasm context, and speaks MCP over\nstdin/stdout — so a static site (e.g. a GitHub Pages *user* site) becomes an\nMCP server on your machine with zero deployed infrastructure on either side:\n\n```\nnpx -y @rckflr/mcpwasm https://usuario.github.io\n```\n\nMCP client configuration (Claude Code, Cursor, …):\n\n```\n{\n  \"mcpServers\": {\n    \"misitio\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@rckflr/mcpwasm\", \"https://usuario.github.io\"]\n    }\n  }\n}\n```\n\n**Publishers under a path work.** The origin may carry one — a GitHub Pages\n*project* site (`https://user.github.io/REPO`\n\n) is discovered at\n`<base>/llms.txt`\n\n, not at the host root, and `host.fetchOrigin`\n\nis scoped to\nthat subpath rather than to the whole host, so one project cannot reach\nanother's endpoints on a shared host. The same canonical origin is used by the\ngateway and by `scripts/attest.mjs`\n\n, so an attestation signed for a project\nsite verifies. (Pointing at a *raw* GitHub URL still does not work — see\n`--serve`\n\nbelow.)\n\nHonest limits (stated in `bin/mcpwasm-local.mjs`\n\n): discovery runs once per\nprocess (restart to refresh). Hash verification and the sandbox model\n(per-skill contexts, origin-scoped `fetchOrigin`\n\n, resource limits) are the\nsame as the gateway's. **Origin memory is supported**: if the origin declares\n`skills-memory`\n\n, the snapshot is fetched, verified against `snapshot_sha256`\n\n,\nand `host.memorySearch`\n\nis injected — same contract as the gateway; on any\nmismatch (or if the optional `@rckflr/minimemory`\n\nengine is missing) the\ncapability is simply absent and skills that call it fail closed in-sandbox.\nEach verified skill's `SKILL.md`\n\nrecipe is served as an MCP resource and via\nthe `get_skill_guide`\n\ntool (see \"Skill recipes as MCP resources\"). Trust is\nyour choice of origin by default (no attestation required); Sigstore\nattestation verification is available opt-in via `--require-attestation`\n\n(below). Also cross-checks `tool_sha256`\n\nagainst\n`.well-known/agent-skills/index.json`\n\nwhen the origin publishes one (see\n\"Cross-checking against index.json\"). Tested by `npm run local`\n\n(hermetic,\nlocalhost-only; part of the CI gate — including verified-snapshot search and\nthe tampered-snapshot fail-closed path).\n\nBoth the local runtime and the gateway now also fetch\n`/.well-known/agent-skills/index.json`\n\n— the canonical metadata layer the core\nllms-txt-skills RFC defines (§8 Open Question 5: `llms.txt`\n\nis the zero-fetch\ndiscovery pointer, `index.json`\n\nis the metadata/verification source of truth).\nWhen a skill's name appears in both `llms.txt`\n\nand `index.json`\n\n, and the\nlatter declares a `tool_sha256`\n\n, it **must** match the one declared in\n`llms.txt`\n\n; a mismatch rejects the skill (drift/tampering signal) exactly like\na `tool_sha256`\n\nmismatch against the fetched `tool.js`\n\nitself. Absence of\n`index.json`\n\n(most origins today) changes nothing.\n\nPlatform limitation, discovered during implementation:the`sigstore`\n\nnpm package depends on`@sigstore/tuf`\n\nto cache Fulcio/Rekor's trusted root via TUF, and that cache uses`node:fs`\n\nwith no way to bypass it through the public API. Cloudflare Workers has no filesystem, so Sigstore verificationonly runs in the local Node runtime(`bin/mcpwasm-local.mjs`\n\n) — the gateway's attestation model remains the pre-registered-key Ed25519 scheme above, unaffected. This is the one capability asymmetry between the two runtimes; see[Capability support by runtime]. The spec (v0.4 §2.4) now names this profile explicitly: Sigstore is the RECOMMENDED default where verification can reach Sigstore's trust infrastructure; pre-registered Ed25519 is the profile for platforms — like Workers — where it cannot.\n\nThe Ed25519 model (above) requires pre-registering every reviewer's public\nkey — a real bottleneck (today, only the maintainer is registered). Sigstore\nverifies **any** OIDC identity (a GitHub Actions workflow, a Google/GitHub\nlogin) without pre-coordination; the runtime's trust decision is which\n*identity* to require, not which *key* to whitelist — closer to what core RFC\n§4.6 recommends for identity-bound provenance.\n\n```\nnpx -y @rckflr/mcpwasm https://usuario.github.io \\\n  --require-attestation \"https://token.actions.githubusercontent.com|https://github.com/OWNER/REPO/.github/workflows/release.yml@refs/heads/main\"\n```\n\nWhen set, discovery additionally fetches\n`/.well-known/agent-skills/attestations.json`\n\nand, for **every** skill,\nrequires a matching entry (`origin`\n\n+ `skill`\n\n+ `tool_sha256`\n\n) whose\n`sigstore_bundle`\n\nverifies against exactly that `issuer|identity`\n\npair, within\nits `[signed_on, valid_until]`\n\nwindow. A skill without one — absent\nattestations.json, no matching entry, expired, or an invalid/mismatched bundle\n— is excluded, same treatment as a `tool_sha256`\n\nmismatch. This flag is\nfail-closed by design: it is opt-in, but once set, absence of a valid\nattestation is *not* tolerated (unlike the gateway's `advisory`\n\nmode).\n\nThe attestation object's signed payload is an\n[in-toto Statement v1](https://in-toto.io/Statement/v1) inside a DSSE\nenvelope, whose `predicate`\n\nmust carry the same 5 fields as the Ed25519 model\n(`origin`\n\n, `skill`\n\n, `tool_sha256`\n\n, `signed_on`\n\n, `valid_until`\n\n) — verified to\nmatch the attestation's own top-level fields, so a validly-signed bundle for\nskill A cannot be relabeled as skill B's attestation without re-signing.\n`sigstore-attest.mjs`\n\nexports `verifySigstoreAttestation`\n\n(the verifier) and\n`buildSigstoreStatement`\n\n(the canonical payload shape a publisher signs with\n`sigstore attest`\n\nor an equivalent SDK call — this repo does not ship a\nsigning tool for it, since producing a real Sigstore signature needs a live\nOIDC flow, out of scope for a script run here).\n\nVerified against a **real, live, publicly fetched** Sigstore bundle (the SLSA\nprovenance attestation for the `sigstore@5.0.0`\n\nnpm package's own publish,\n`https://registry.npmjs.org/-/npm/v1/attestations/sigstore@5.0.0`\n\n) — proving\nthe underlying Fulcio cert-chain + Rekor transparency-log verification\ngenuinely runs and succeeds, and that a schema-mismatched or wrong-identity\nbundle is correctly rejected. All 6 `--require-attestation`\n\nrejection paths\n(no attestations.json, empty array, no matching skill, malformed date,\nexpired, invalid/empty bundle) verified end-to-end against\n`bin/mcpwasm-local.mjs`\n\n. **Honest gap:** producing a *positive* fixture (a\nreal Sigstore signature over this repo's own canonical payload, verifying as\n`attested`\n\n) needs a live OIDC signing flow this environment cannot complete\nheadlessly — untested is the happy path specifically, not the security-critical\nrejection paths.\n\nPointing the runtime at a raw GitHub URL does **not** work: `new URL(...).origin`\n\nkeeps only scheme+host+port, so `https://raw.githubusercontent.com/you/repo/main/`\n\ncollapses to `https://raw.githubusercontent.com`\n\n— the `you/repo/main`\n\npart (and\ntherefore `/llms.txt`\n\n) is gone. `--serve`\n\nis the practical alternative: it starts\nan internal static file server (bound to `127.0.0.1`\n\nonly, never exposed to the\nnetwork) over a local directory — e.g. your own `git clone`\n\nof a skills repo —\nand uses that as the origin, combining \"serve this directory\" and \"connect to\nit\" into one command:\n\n```\nnpx -y @rckflr/mcpwasm --serve ./my-skills-repo\n# npx -y @rckflr/mcpwasm --serve ./my-skills-repo --port 4000   (fixed port, optional)\n```\n\nThis is meant for developing and testing your own skills locally before\npublishing them (to GitHub Pages or any other static host) — not for browsing\nsomeone else's GitHub repo directly. Path-traversal requests against the\ninternal file server are rejected (resolved and checked against the served\ndirectory's root); covered by `npm run local`\n\n.\n\nMCP clients (Claude, Cursor, others) can call arbitrary tools. Running a third-party tool's code directly in your backend means that code can read your secrets, hit your DB, phone home, or loop forever. You either trust the author fully or you don't run the tool.\n\nmcpwasm removes the trust requirement for the *code*:\n\n- The tool runs in a separate QuickJS-wasm context with no host globals beyond\nwhat the host predefines (\n`registerTool`\n\n,`host`\n\n). No`fetch`\n\n, no`process`\n\n, no disk, no secrets. - The platform secret (e.g. a Stripe key) stays on the host side. The tool can\nonly ask the host to perform a\n*named*internal action via`host.callInternal`\n\n(sync host) or a scoped`host.fetchOrigin`\n\n(async host). The host decides what is allowed. - The tool's\n`tool.js`\n\nis content-addressed by SHA-256; the gateway refuses to load it if the hash declared in`llms.txt`\n\ndoes not match the bytes it fetched. - Resource limits bound what a malicious/buggy tool can do: memory cap, stack cap, a deterministic gas budget (interrupt-handler invocation count), and a wall-clock fetch deadline per call.\n\nIn mcpwasm, *publishing* (static files + a hash) and *execution* (a runtime\nthat discovers, verifies, and sandboxes on demand) are two separate things. In\na traditional MCP server they are the same thing — the server you deploy *is*\nthe execution, with no isolation layer in between.\n\nStatic MCP — local (`npx @rckflr/mcpwasm` ) |\nStatic MCP — gateway | Traditional MCP server | |\n|---|---|---|---|\n| What the publisher ships | Static files: `llms.txt` + `tool.js` (+ `SKILL.md` ) |\nSame static files | A running server process (any language) |\n| Infrastructure the publisher operates | None — GitHub Pages, R2, any static host | None — same | The whole server: uptime, scaling, patching, secrets |\n| Where the tool code runs | Your own machine, in a QuickJS-wasm sandbox | The gateway Worker, in the same sandbox | The publisher's own process, natively, no isolation |\n| Integrity guarantee | SHA-256 verified before any byte executes | Same | None built in — you trust the deployed binary/image as-is |\n| Third trust ring (human review) | Not enforced (v1 limit — trust is your choice of origin) | Ed25519 attestations, `enforcing` mode in production |\nNo standard mechanism |\n| Transport | stdio (JSON-RPC over stdin/stdout) | HTTP POST (JSON-RPC); needs the gateway URL + a token | Either — but fixed per implementation |\n| Network hops for the MCP call itself | Zero (local process); the tool can still call out via `fetchOrigin` |\nTwo: client → gateway → publisher origin | Zero for local stdio, one for remote HTTP |\n| Measured overhead (this repo's own benchmarks) | Not separately benchmarked — same sandbox cost, no gateway hop | ~2 ms sandbox warm, ~6 ms for the full gateway vs. a direct API call (\n|\n\nThe takeaway that doesn't fit in a table: a traditional MCP server answers \"how\ndo I expose this logic as a tool?\" Static MCP additionally answers \"how do I\nrun code from an origin I don't fully trust?\" If you write and control the\nserver yourself, traditional is simpler and none of this is necessary. Static\nMCP matters when the tool code comes from *someone else*, and you want a\nverifiable guarantee (hash + sandbox, optionally review) before running it —\nthat is the problem this repo exists to solve, not a general-purpose\nalternative to MCP.\n\nYou do not need to build or maintain an MCP protocol server — that is the\nwhole point. The runtime (local or gateway) already handles JSON-RPC,\n`tools/list`\n\n, `tools/call`\n\n, and the transport; none of that is your code.\n\nWhat you still have to write is **not** prose. A `tool.js`\n\nper action is real,\nsmall glue code: it validates `args`\n\nagainst the schema you declared, calls\nyour existing API through `host.fetchOrigin`\n\n, and shapes the response — see\n`bookstore/content/create_order.tool.js`\n\nin this repo for a concrete example\n(validates `qty`\n\nand `book_id`\n\n, handles a 409 for insufficient stock as a\ndistinct case, never lets a malformed call reach your backend). This is a\ndifferent, stronger mechanism than a `SKILL.md`\n\nwith no `tool.js`\n\n: prose-only\nskills are the core RFC's basic mode — an agent reads them and *improvises*\nthe HTTP call with whatever generic request tool it has, with no schema\nvalidation, no sandbox, and no hash pinning. That is the \"execution gap\" that\nexecutable skills (this repo's reference feature) close. Handing an agent a\nraw \"make any HTTP request\" capability against your API reintroduces the\nproblem this project exists to avoid — your backend ends up validating\nagainst an arbitrary caller either way; a `tool.js`\n\ndoes that validation\nbefore your API is ever hit, and the agent only ever gets the specific,\nparameterized actions you defined.\n\n\"Zero infrastructure\" is literal for internal use — your own team pointing\n`npx @rckflr/mcpwasm`\n\n(or `--serve`\n\n) at your published skills needs no server\non either side. For external clients to reach you without installing\nanything, you need one endpoint answering MCP over HTTP; that means either\nyour origin gets added to an existing deployed gateway's `ALLOWED_ORIGINS`\n\n,\nor you `wrangler deploy`\n\nyour own instance of the same generic gateway code in\nthis repo, configured for your origin. Either way it is a one-time,\ntool-agnostic deploy — not a bespoke MCP server built per API.\n\n```\n                         (1) publish llms.txt + tool.js + SKILL.md\n   Publisher site  ───────────────────────────────────────────┐\n   (static: R2/Pages/                                          │\n    any host serving /llms.txt)                                │\n                                                               ▼\n                                                        ┌───────────────┐\n   MCP client  ──POST /mcp?origin=<pub>──►  Gateway Worker │ discovers     │\n   (Claude,                                            │ llms.txt,     │\n    Cursor, ...)                                        │ verifies     │\n        ▲                                               │ sha256 per    │\n        │  (5) JSON-RPC response                         │ skill,        │\n        └──────────────────────────────────────────────  │ loads tools   │\n                                                        │ in sandbox    │\n                                                        └──────┬────────┘\n                                                               │ (2) per request:\n                                                               │     new QuickJS\n                                                               │     context,\n                                                               │     origin-scoped\n                                                               ▼\n                                                        ┌───────────────┐\n                                                        │ AsyncToolHost │  (3) tool code\n                                                        │ (QuickJS-wasm │      calls\n                                                        │  asyncify)    │      host.fetchOrigin\n                                                        │               │────► (4) host fetches\n                                                        │  mem/stack/   │      ONLY the allowed\n                                                        │  interrupt    │      origin, returns\n                                                        │  limits set   │      {status,body,\n                                                        │               │       truncated,...}\n                                                        └───────────────┘\n```\n\nFlow:\n\n- A publisher site ships\n`/llms.txt`\n\nplus per-skill`tool.js`\n\nand`SKILL.md`\n\nfiles.`llms.txt`\n\nlists each executable skill with a SHA-256 of its`tool.js`\n\n, and may declare an*origin memory*snapshot (see below). - On each request the gateway downloads\n`llms.txt`\n\n, parses the executable skills, downloads each`tool.js`\n\n, verifies SHA-256, and loads the verified ones into a fresh`AsyncToolHost`\n\nscoped to that origin. - Tool code runs inside QuickJS-wasm. It can only call\n`host.fetchOrigin(path, opts?)`\n\n(`opts`\n\n:`{method: \"GET\"|\"POST\", body?: string ≤16 KB, contentType?}`\n\n— write skills go through POST; returns`{status, body, truncated, bytes, contentLength}`\n\n, see the limits section for what`truncated`\n\nmeans and why it exists), which is async from the host side but synchronous-looking inside the sandbox (QuickJS asyncify suspends/resumes the wasm stack). An origin that declares a verified memory snapshot additionally gets`host.memorySearch(query, k?)`\n\n. - The host fetches only the allowed origin; any other origin throws inside the sandbox.\n- The gateway maps MCP\n`tools/list`\n\nand`tools/call`\n\nover JSON-RPC 2.0 and returns the result to the client.\n\nPieces live in:\n\n`host.mjs`\n\n— synchronous`ToolHost`\n\n(sync tools,`host.callInternal`\n\ncapability).`host-async.mjs`\n\n—`AsyncToolHost`\n\n(async handlers,`host.fetchOrigin`\n\n+ the`extraCapabilities`\n\nmechanism that backs`host.memorySearch`\n\n, resource hardening).`mcp-core.mjs`\n\n/`mcp-core-async.mjs`\n\n— JSON-RPC 2.0 MCP core (transport-agnostic).`worker.mjs`\n\n— PoC MCP server (sync host, inline tools).`worker-spike.mjs`\n\n— async spike (fetchHome/fetchEvil).`worker-gateway.mjs`\n\n+`llmstxt-parse.mjs`\n\n— the gateway.`worker-memspike.mjs`\n\n— memory spike: the docs-site origin published and served through the gateway end-to-end (`host.memorySearch`\n\nover a BM25 snapshot), exercised by`mf-memspike.mjs`\n\n.\n\nStatus: Draft v0.5, adopted.This format is specified by the[Executable Skills extension]of the[llms-txt-skills]standard. This repo is its reference implementation; the spec and this code are kept aligned (every MUST in the spec is field-tested here).\n\nUnder a `## Skills`\n\nsection, an executable skill is a normal markdown list item\nfollowed by an HTML comment carrying a JSON object with `version`\n\n, `tool`\n\n(path\nto the `tool.js`\n\n), and `tool_sha256`\n\n(hex SHA-256 of the `tool.js`\n\nbytes):\n\n```\n- [sum_numbers](/skills/sum_numbers/SKILL.md): Sum two numbers a and b. <!-- skill: {\"version\":\"1.0.0\",\"tool\":\"/skills/sum_numbers/tool.js\",\"tool_sha256\":\"58daf86111bf7278446eb7e0e8c6384713b50cdb6fa97ac039e23846d723dc3e\"} -->\n```\n\nParsed by `llmstxt-parse.mjs`\n\n:\n\n- The\n`<!-- skill: {...} -->`\n\ncomment marks the line as an*executable*skill. List items without it are treated as descriptive-only and ignored by the gateway. `tool`\n\nis resolved relative to the origin.`tool_sha256`\n\nis verified against the fetched`tool.js`\n\nbytes before the tool is loaded. Mismatch → the skill is rejected (logged) and not registered.- If the JSON is invalid the line is silently skipped (no throw).\n\nA `tool.js`\n\nregisters itself:\n\n```\nregisterTool({\n  name: \"sum_numbers\",\n  description: \"Sum two numbers a and b.\",\n  inputSchema: { type: \"object\", properties: { a: { type: \"number\" }, b: { type: \"number\" } }, required: [\"a\", \"b\"] },\n  handler(args) { return Number(args.a) + Number(args.b); }\n});\n```\n\nSpec:\n\norigin memoryin[Executable Skills v0.5].\n\nBoth runtimes.Origin memory runs in thegateway(`worker-gateway.mjs`\n\n) and in thelocal runtime(`bin/mcpwasm-local.mjs`\n\n), with the same contract: snapshot fetched, verified against`snapshot_sha256`\n\n, and only then is`host.memorySearch`\n\ninjected. In the local runtime the BM25 engine (`@rckflr/minimemory`\n\n) is anoptionalDependency— installed by default with`npx`\n\n/`npm install`\n\n; if it is missing, the runtime says so on stderr and degrades to \"no memory\" (capability absent, skills that call it fail closed in-sandbox — spec-conformant, not a crash).\n\nA publisher that wants its skills to search over its own static content (docs,\ncatalog text, any corpus) declares a memory snapshot with a single HTML comment\n**before** the `## Skills`\n\nsection (this ordering is required by the reference\nparser):\n\n```\n<!-- skills-memory: {\"snapshot\":\"/skills-index.snapshot\",\"snapshot_sha256\":\"a0235f071aa7e28f2096312f22f1ad035901595f3fa91d2cc92b5879bbb7f6d5\",\"format\":\"minimemory-okf-v1\"} -->\n\n## Skills\n...\n```\n\n`snapshot`\n\nis a path (relative to the origin) to a BM25 snapshot in the`minimemory-okf-v1`\n\nformat (built by theengine; the wasm binary ships as`@rckflr/minimemory`\n\n`minimemory_bg.wasm`\n\n).`snapshot_sha256`\n\nis the hex SHA-256 of the snapshot bytes. Both runtimes download the snapshot and**verify it against this hash before injecting the capability**— same content-addressing rule as`tool.js`\n\n. On mismatch, fetch failure, non-200, or an unsupported`format`\n\n, the snapshot is discarded and**the capability is not injected**.- When the snapshot verifies, the runtime injects\n`host.memorySearch(query, k?)`\n\ninto every skill of that origin (via the`extraCapabilities`\n\nbridge in`AsyncToolHost`\n\n, same raw-JSON asyncify pattern as`host.fetchOrigin`\n\n).`k`\n\ndefaults to 5 and is clamped to`[1, 10]`\n\n. It returns`{ hits: [{ text, score, title, concept_id }] }`\n\n(or`{ error }`\n\n). - Without a verified snapshot the capability is absent: a skill that calls\n`host.memorySearch`\n\nsees`undefined`\n\nand throws**inside the sandbox**, surfacing as`isError: true`\n\n(controlled failure, not a runtime crash). The skills still list — only the memory capability is missing.\n\nThe reference publisher is the docs-site (see \"Repository layout\"): it serves\nthe spec snapshot and a `search_spec`\n\nskill that runs\n`host.memorySearch(args.q, k)`\n\nto do BM25 search over the four llms-txt-skills\ndocuments, plus `get_doc`\n\nand `list_docs`\n\n.\n\nDiscovery, `tool_sha256`\n\ncontent-addressing, sandboxed `tool.js`\n\nexecution,\norigin memory (`host.memorySearch`\n\n), skill recipes as MCP resources, and\nEd25519 attestations work on **both** runtimes. One capability remains asymmetric, for a declared platform reason,\nnot a bug:\n\n| Capability | Gateway (`worker-gateway.mjs` , Workers) |\nLocal runtime (`bin/mcpwasm-local.mjs` , Node) |\n|---|---|---|\nOrigin memory — `host.memorySearch` |\n✅ full | ✅ full (engine is an optionalDependency; if missing, degrades to capability-absent) |\n| Sigstore (keyless) attestations | ❌ Workers has no `node:fs` for `@sigstore/tuf` 's trust-root cache |\n✅ `--require-attestation` |\n\nConsequence: the **local runtime is the full-featured reference** — memory and\nSigstore both work there. The gateway matches it except for Sigstore\nverification, where its attestation model remains Ed25519-only (platform\nlimitation of Workers, documented in\n[Sigstore](#sigstore-attestations---require-attestation-local-runtime-only)\nabove).\n\nHash pinning verifies bytes against what the publisher declares **today**.\nIf the publisher — or whoever compromised their account — changes `tool.js`\n\n*and* its declared hash together, a consumer receives the new code\nsilently. `--lock`\n\ncloses that gap with pin-on-first-use:\n\n```\nnpx -y @rckflr/mcpwasm https://example.com --lock skills.lock\n```\n\n-\nFirst use pins each skill's declared\n\n`tool_sha256`\n\nand recipe`sha256`\n\n. -\nA later change is\n\n**rejected loudly**(that skill only; the rest load):\n\n``` php\nskill rechazada: search_knowledge -> LOCK MISMATCH: el publicador cambio tool_sha256 …\n```\n\n-\nIf the change is a legitimate update, accept it explicitly:\n\n```\nnpx -y @rckflr/mcpwasm https://example.com --lock skills.lock --lock-update\n```\n\nNew skills are pinned with a notice. Memory snapshots are deliberately not\nlocked — knowledge changes legitimately with every content update; code and\ninstructions should not change without you noticing. Commit `skills.lock`\n\nnext to your agent config, like a package lock.\n\nThe third runtime, since 0.7.0.\n\nLive demo:[https://mauricioperera.github.io/mcpwasm/demo/]— same trust model as the local runtime, with Node removed from the equation.\n\nThe whole consumer side runs in a browser tab: discovery, byte-for-byte\nSHA-256 verification (`crypto.subtle`\n\n), one QuickJS-wasm sandbox per verified\ntool, scopes, per-scope origin memory and verified SKILL.md recipes. The\npublisher only needs CORS (GitHub Pages already serves\n`Access-Control-Allow-Origin: *`\n\n).\n\n``` js\nimport { connectStaticSkills } from \"@rckflr/mcpwasm/web\";\n\nconst skills = await connectStaticSkills(\"https://mauricioperera.github.io\", {\n  quickjsWasm: \"./emscripten-module.wasm\",   // URL, bytes or WebAssembly.Module\n  // optional BM25 memory:\n  minimemoryWasm: \"./minimemory_bg.wasm\",\n  minimemoryInit: (bytes) => { initSync({ module: bytes }); return WasmOkfIndex; },\n  onLog: console.log,\n});\nskills.tools;                                  // verified, scope-renamed\nawait skills.callTool(\"search_knowledge\", { q: \"how do I publish?\" });\nskills.recipes;                                // verified SKILL.md per tool\n```\n\nBundle it with any bundler (`esbuild --bundle --platform=browser`\n\n) and serve\nthe two wasm files next to it — `npm run build:web`\n\ndoes exactly that into\n`docs/demo/`\n\n. The module is environment-agnostic: the same code runs in\nNode 20+, which is how CI smoke-tests it (`npm run test:web`\n\n, hermetic local\npublisher).\n\nThis enables fully serverless agent stacks in the browser — e.g. a\n[wasm-agents](https://github.com/mozilla-ai/wasm-agents-blueprint)-style\nHTML agent whose tools are verified static skills: no server, no Node,\nnothing to install on either side.\n\nSpec: §2.5 of\n\n[Executable Skills v0.5](resolves RFC v0.10 Open Question 6). Both runtimes, since 0.6.0.\n\nOne origin (e.g. a GitHub Pages root site) can aggregate skills from several\nprojects. Without namespacing, two projects that both publish a\n`search_knowledge`\n\ntool collide, and only one `skills-memory`\n\nline can exist\nper origin. Scopes fix both:\n\n```\n<!-- skills-memory: {\"snapshot\":\"/KDD/skills-index.snapshot\",\"snapshot_sha256\":\"…\",\"format\":\"minimemory-okf-v1\",\"scope\":\"kdd\"} -->\n\n## Skills\n\n- [search_knowledge](/KDD/skills/search_knowledge/SKILL.md): … <!-- skill: {\"version\":\"1.0.0\",\"tool\":\"/KDD/skills/search_knowledge/tool.js\",\"tool_sha256\":\"…\",\"scope\":\"kdd\"} -->\n```\n\n`scope`\n\n(optional, pattern`^[a-z][a-z0-9_-]*$`\n\n) declares the project namespace of a skill line. The runtime exposes the tool under the public name(`<scope>__<toolName>`\n\n`kdd__search_knowledge`\n\n); the rename happens at the**host boundary only**— the published`tool.js`\n\nbytes, its`tool_sha256`\n\n, and any attestations are untouched. This preserves the universal-template property: the same`tool.js`\n\ncan be served by every publisher under different scopes with one ecosystem-wide hash.- One\n`skills-memory`\n\nline**per scope**(at most one without`scope`\n\n). Each skill gets`host.memorySearch`\n\nbound to**its own scope's** verified snapshot — memories are isolated per project. - An invalid\n`scope`\n\nvalue makes the line non-executable (reported, not loaded). A public-name collision (two lines mapping to the same public name) keeps the first and skips the rest with a diagnostic. - Skill recipes follow the public name:\n`skill://kdd__search_knowledge`\n\n. - Fully backward compatible: no\n`scope`\n\n⇒ exactly the pre-0.6.0 behavior.\n\nBoth runtimes. An executable skill has two halves: the\n\nrecipe(`SKILL.md`\n\n— when/how to use it, sequencing, constraints; the \"recipe layer\" the core RFC §3.3 defines) and thecapability(`tool.js`\n\n). Serving only the tools loses the recipe — the agent gets the hammer without the manual.\n\nDiscovery also fetches each verified skill's `SKILL.md`\n\nand verifies it\nagainst the `sha256`\n\ndeclared in the same `llms.txt`\n\nline (core RFC field).\nVerified recipes are exposed two ways:\n\n**MCP resources**—`resources/list`\n\n/`resources/read`\n\n, uri`skill://<name>`\n\n,`text/markdown`\n\n; the capability is advertised in`initialize`\n\n. The semantically correct path for clients that support it.— a synthetic, runtime-provided (not sandboxed) tool returning the recipe by skill name. Universal fallback: every MCP client supports tools.`get_skill_guide`\n\ntool\n\nFailure semantics mirror everything else here, but the halves are\n**independent**: a missing/unfetchable/hash-mismatched `SKILL.md`\n\nomits the\n*recipe* (warned on stderr / console) while the *tool* — verified by its own\n`tool_sha256`\n\n— loads unaffected. Under `enforcing`\n\nattestation mode, an\nexcluded skill's recipe is excluded with it. Size cap: `MAX_SKILLMD_BYTES`\n\n(default 256 KB). Covered by `npm run local`\n\n(verified recipe served, tampered\nrecipe excluded, missing recipe tolerated).\n\nSpec:\n\n[Skill Attestations v0.4].\n\nThis section describes the **gateway's** model: Ed25519 signatures from a\nruntime-side pre-registered `REVIEWERS`\n\nkey registry. The **local runtime**\nadditionally supports **Sigstore (keyless)** attestations via\n`--require-attestation`\n\n— no pre-registered key, any OIDC identity the runtime\nexplicitly trusts — see \"Sigstore attestations\" above; that section closes\nthe \"only one registered reviewer scales\" bottleneck this one has.\n\nA publisher may serve a third trust ring — signed reviewer attestations — at\n`/.well-known/agent-skills/attestations.json`\n\n. Each entry is an Ed25519\nsignature over the canonical payload and has this shape:\n\n```\n{\n  \"origin\": \"https://llmstxt-docs.rckflr.workers.dev\",\n  \"skill\": \"search_spec\",\n  \"tool_sha256\": \"95301993...\",\n  \"attester\": \"human:mauricio\",\n  \"signed_on\": \"2026-07-02\",\n  \"valid_until\": \"2027-07-02\",\n  \"signature\": \"<base64 Ed25519 signature>\"\n}\n```\n\nThe signed payload is the UTF-8 bytes of\n`origin + \"\\n\" + skill + \"\\n\" + tool_sha256 + \"\\n\" + signed_on + \"\\n\" + valid_until`\n\nwith `origin`\n\ncanonical (lowercase, no trailing slash, no default port) and\n`tool_sha256`\n\nlowercase hex. The gateway:\n\n- Fetches\n`attestations.json`\n\nduring discovery (only when attestations are not`off`\n\n). A 404 or malformed array means \"no attestations\" (every skill is`unattested`\n\n), not a discovery error. - Verifies each signature with WebCrypto (\n`Ed25519`\n\nvia`crypto.subtle`\n\n, public key imported raw) against the runtime-side reviewer registry`REVIEWERS`\n\n(a`REVIEWERS`\n\nvar in`wrangler-gateway.toml`\n\nmapping`attester → { public_key: <base64 raw 32 bytes>, registered_at }`\n\n). An attester not in the registry is ignored; a registered attester whose signature fails marks the skill`invalid`\n\n. - Computes a per-skill verdict with precedence\n**invalid > attested > expired > unattested**(`invalid`\n\ndominates): a matching attestation from a registered reviewer with a valid signature inside its`[signed_on, valid_until]`\n\nwindow is`attested`\n\n; valid signature outside the window is`expired`\n\n; no matching attestation is`unattested`\n\n. - Exposes the verdicts two ways: a tag appended to each tool's\n`description`\n\nin`tools/list`\n\n, and a summary header`X-Gw-Attestations`\n\n(`attested=N,expired=N,invalid=N,unattested=N`\n\n) on every response.\n\nThree modes via `ATTESTATION_MODE`\n\n:\n\n`off`\n\n(default when`ATTESTATION_MODE`\n\nis unset) — attestations are not fetched; behavior is the pre-T25 gateway.`advisory`\n\n— everything loads; verdicts are visible but do not exclude.`enforcing`\n\n(deployed since T45) — only`attested`\n\nskills load; non-`attested`\n\nskills are excluded exactly like a`tool_sha256`\n\nmismatch (logged, not registered).\n\n`scripts/attest.mjs`\n\nis the signing tool (Node `node:crypto`\n\nEd25519, no deps):\n\n`node scripts/attest.mjs keygen`\n\n— generates an Ed25519 pair, writes the private key to`.attester-key.json`\n\nand prints**only the public key**(base64 raw 32 bytes) for the`REVIEWERS`\n\nregistry.`node scripts/attest.mjs sign <origin> <skill> <valid_until>`\n\n— fetches the origin's live`llms.txt`\n\n, reads the real`tool_sha256`\n\nfor the skill, signs, and prints the attestation object JSON.`… sign <origin> --all <valid_until>`\n\n— signs**every** executable skill the`llms.txt`\n\ndeclares and prints the whole array, ready to paste into`attestations.json`\n\n. Signing one at a time is how the third one gets forgotten.`… --llms <file>`\n\n/`… --from-worker <file>`\n\n— read the`llms.txt`\n\nfrom a**local** source instead of the live origin: a file, or the`LLMS_TXT`\n\nembedded in a freshly built worker. This is what lets you sign*before*deploying. Reading the live origin forces the opposite order — deploy, then sign — and under`enforcing`\n\nthat leaves a window where the new hashes have no matching attestation, so the gateway excludes those skills entirely.\n\nThe private key lives in `.attester-key.json`\n\nand is **local and gitignored** —\nnever commit it, and it is never printed by the tool. No key material belongs\nin this repo or in `REVIEWERS`\n\n(only public keys).\n\nThird-party publishers (sites you do not control): see [ ONBOARDING.md](/MauricioPerera/mcpwasm/blob/main/ONBOARDING.md)\nfor the eligibility, review, attestation, activation, and revocation process.\n\nRequirements: Node 18+ and `npm install`\n\n(already done in this checkout).\n\n```\nnpm install\nnpm test      # build + e2e Miniflare for the sync PoC (worker.mjs)\nnpm run spike # build + e2e Miniflare for the async spike (worker-spike.mjs)\nnpm run gateway # build + e2e Miniflare for the gateway (worker-gateway.mjs) — hits the live demo site\nnpm run memspike # build the memory snapshot + memspike worker, then e2e Miniflare against the docs-site origin (host.memorySearch / BM25)\n```\n\nThe rest of the suites, all hermetic (no network) and all part of the CI gate:\n\n```\nnpm run gateway:offline # the gateway suite with fake origins — no network at all\nnpm run local     # the stdio runtime end to end against a fake publisher\nnpm run subpath   # publishers living under a path (project sites), both runtimes\nnpm run test:web  # the browser runtime, running the same module in Node\nnpm run test:bundle  # the BUILT demo bundle, i.e. the artifact GitHub Pages serves\nnpm run test:attest  # sign offline with attest.mjs, verify the real gateway accepts it\n```\n\nAnd the drift guards, which fail if a committed generated artifact no longer matches its source — the failure mode that let the demo bundle sit five fixes behind its own source across four merges:\n\n```\nnpm run check:bundle      # docs/demo/mcpwasm-web.js\nnpm run check:docs-site   # docs-site/worker.mjs (+ snapshot, provenance)\nnpm run check:publishers  # demo-site/ and bookstore/ workers\n```\n\n`npm run gateway`\n\nis documented as-is from `package.json`\n\n; it builds the gateway\nworker and runs `mf-gateway.mjs`\n\nagainst the real deployed demo site. `npm run memspike`\n\ndoes the same for the memory capability: `build-memsnapshot.mjs`\n\n→\n`build-memspike.mjs`\n\n→ `mf-memspike.mjs`\n\n.\n\nThe gateway is live at `https://llmstxt-gateway.rckflr.workers.dev`\n\n. It is\nrestricted to origins in its allowlist; the demo site\n`https://llmstxt-demo-site.rckflr.workers.dev`\n\n, the bookstore\n`https://llmstxt-bookstore.rckflr.workers.dev`\n\n(D1-backed, includes a write\nskill `create_order`\n\n), and the docs-site\n`https://llmstxt-docs.rckflr.workers.dev`\n\n(origin memory / BM25) are allowed.\n`origin`\n\nis URL-encoded as a query param. **The deployed gateway has auth\nenabled:** every request below needs `-H \"Authorization: Bearer <AUTH_TOKEN>\"`\n\n(the `AUTH_TOKEN`\n\nsecret; 401 otherwise). The token is a secret — it is not in\nthis repo. The deployed gateway can also run in **per-client mode** (the\n`CLIENTS`\n\nsecret), in which case each client sends its own\n`Authorization: Bearer <client_token>`\n\nwith the same curl syntax; the response\nthen carries `X-Gw-Client: <client_id>`\n\n.\n\nList the skills the demo site publishes:\n\n```\ncurl -s -X POST \\\n  \"https://llmstxt-gateway.rckflr.workers.dev/mcp?origin=https%3A%2F%2Fllmstxt-demo-site.rckflr.workers.dev\" \\\n  -H \"Authorization: Bearer <AUTH_TOKEN>\" \\\n  -H \"content-type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'\n```\n\nCall `sum_numbers`\n\n(pure sync tool, runs in the sandbox):\n\n```\ncurl -s -X POST \\\n  \"https://llmstxt-gateway.rckflr.workers.dev/mcp?origin=https%3A%2F%2Fllmstxt-demo-site.rckflr.workers.dev\" \\\n  -H \"Authorization: Bearer <AUTH_TOKEN>\" \\\n  -H \"content-type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"sum_numbers\",\"arguments\":{\"a\":2,\"b\":40}}}'\n```\n\nCall `server_time`\n\n(async tool that calls `host.fetchOrigin(\"/api/time\")`\n\non the\nallowed origin):\n\n```\ncurl -s -X POST \\\n  \"https://llmstxt-gateway.rckflr.workers.dev/mcp?origin=https%3A%2F%2Fllmstxt-demo-site.rckflr.workers.dev\" \\\n  -H \"Authorization: Bearer <AUTH_TOKEN>\" \\\n  -H \"content-type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"server_time\",\"arguments\":{}}}'\n```\n\nThe other deployed workers (their root path returns 404 by design — only\nspecific routes like `/llms.txt`\n\nare served):\n\n- PoC sync host:\n`https://toolhost-mcp.rckflr.workers.dev`\n\n(POST`/mcp`\n\n). - Demo publisher:\n`https://llmstxt-demo-site.rckflr.workers.dev/llms.txt`\n\n. - Bookstore publisher:\n`https://llmstxt-bookstore.rckflr.workers.dev/llms.txt`\n\n. - Docs publisher (origin memory):\n`https://llmstxt-docs.rckflr.workers.dev/llms.txt`\n\n.\n\nWhat it guarantees:\n\n**Tool-host isolation.** Tool code runs in a QuickJS-wasm context separate from the Worker's JS. It sees only`registerTool`\n\n,`host`\n\n, and what the host prelude defines. No`fetch`\n\n, no`process`\n\n, no globals leak by default.**Secrets stay outside the sandbox.** In the sync PoC, the platform secret is read from`env.STRIPE_SECRET`\n\non the host side and is never exposed to tool code — the tool can only call named internal methods. In the gateway, there is no platform secret; the only capabilities are`host.fetchOrigin`\n\nand (when declared and verified)`host.memorySearch`\n\n.**SHA-256 content addressing.** The gateway downloads`tool.js`\n\nand verifies it against the`tool_sha256`\n\ndeclared in`llms.txt`\n\nbefore loading. Mismatched or corrupt content is rejected and not cached. The same rule applies to the origin-memory snapshot (`snapshot_sha256`\n\n): unverified → capability not injected.**Skill attestations (third trust ring, spec** See the dedicated section. Publishers may serve signed reviewer attestations; the gateway verifies them via WebCrypto against the runtime-side`ext-skill-attestations`\n\nv0.4).`REVIEWERS`\n\nregistry and exposes per-skill verdicts (`attested`\n\n/`expired`\n\n/`invalid`\n\n/`unattested`\n\n,`invalid`\n\ndominates) in each tool description and the`X-Gw-Attestations`\n\nheader. Modes:`off`\n\n(default when unset) /`advisory`\n\n(everything loads, verdicts visible) /`enforcing`\n\n(only`attested`\n\nskills load; deployed mode since T45).`scripts/attest.mjs`\n\nis the signing tool (keygen + sign).**Origin-scoped fetch.**`host.fetchOrigin`\n\nonly fetches the single allowed origin for the request. Any other origin throws*inside the sandbox*and is surfaced as`isError: true`\n\n, not a JSON-RPC error.**Resource limits (defaults in**`host-async.mjs`\n\n, applied per request):- memory: 64 MB (\n`setMemoryLimit`\n\n) - stack: 1 MB (\n`setMaxStackSize`\n\n) - deterministic gas: 20 000 interrupt-handler invocations per\n`callTool`\n\n/`loadToolSource`\n\n(`setInterruptHandler`\n\nwith an invocation counter). This is the primary cutoff, because`Date.now()`\n\n**freezes** in Cloudflare Workers during synchronous execution (Spectre mitigation), so a pure`while(true){}`\n\nnever advances the clock. The gas counter does not depend on the clock — it counts how many times QuickJS invoked the handler (calibrated ~100× over the heaviest legitimate skill; see TAREA12B). - execution budget: 2000 ms per\n`callTool`\n\n/`loadToolSource`\n\n(a cheap backstop where the clock does advance — Node/tests). It measures**execution**, not wall clock: time the stack spends suspended waiting for a host capability (`fetchOrigin`\n\n,`memorySearch`\n\n) does not count against it, so a slow origin no longer kills a tool that then tries to process the response. Network waits are bounded separately, per fetch, by the outbound deadline below. When either cutoff fires the error says which one (\"gas agotado\" vs \"presupuesto de EJECUCION agotado\") — they used to be the same bare \"interrupted\". - response body cap: 4096\n**bytes** per`host.fetchOrigin`\n\n(`maxResponseBytes`\n\n). The cap is enforced on bytes, so it bounds host memory regardless of encoding. Truncation is**observable**: the tool receives`{status, body, truncated, bytes, contentLength}`\n\n—`truncated`\n\nsays whether the body was cut,`bytes`\n\nhow many were read, and`contentLength`\n\nthe size the origin declared (or`null`\n\nwhen it declares none), so a tool can report the real size of a resource instead of the size of what it got. - outbound fetch deadline: 10 s per\n`host.fetchOrigin`\n\n(`AbortSignal.timeout`\n\n+ a`Promise.race`\n\nbackstop that fires even if the fetch impl ignores the signal; on firing it throws \"fetchOrigin timeout\" inside the sandbox →`isError: true`\n\n, not a gateway crash).\n\n- memory: 64 MB (\n**Fresh context per request.** A new QuickJS context (and runtime) is built per request and disposed at the end; no state survives between requests.**Per-skill contexts in the gateway.** Each skill is loaded into its own QuickJS context; a skill cannot see or overwrite another skill's registration or globals, even within the same origin.**Concurrency safety.** The gateway keeps a pool of up to N independent instances of the asyncify wasm module per isolate (`WASM_POOL_SIZE`\n\n, default 4, clamped to [1, 8]; the compiled`WebAssembly.Module`\n\nis shared, each instance has its own memory). Each request acquires one instance exclusively, so up to N requests run truly in parallel per isolate and the (N+1)-th waits by polling in its own request context (workerd cancels continuations of promises resolved from another request context, so a FIFO handoff is not viable) — with N=1 this degenerates to the previous per-module mutex (TAREA19). Discovery is single-flighted per origin so concurrent cold requests share one discovery pass.\n\nWhat it does **not** guarantee:\n\n**Auth has three modes, selected by config.** Precedence is per-client → legacy shared token → dev open.*Per-client (*`CLIENTS`\n\nsecret, opt-in).`CLIENTS`\n\nis a JSON secret mapping`sha256_hex_of_token → { client_id, rpm? }`\n\n. Tokens never appear in cleartext in config — the key is the lowercase hex SHA-256 of the token's UTF-8 bytes. On`POST /mcp`\n\nthe gateway hashes the`Authorization: Bearer <token>`\n\nvalue and does an exact lookup on that hash; the lookup*is*the timing-safe mechanism (a fixed digest is compared, never the cleartext token against a secret). A known token passes and the response carries`X-Gw-Client: <client_id>`\n\n; an unknown token, missing header, or malformed header yields`401`\n\n.`AUTH_TOKEN`\n\nis ignored in this mode. If`CLIENTS`\n\nis set but its JSON is invalid, the gateway**fail-closes**— every`POST /mcp`\n\nreturns`401`\n\nrather than opening by config error (signalled on`GET /`\n\n).*Legacy shared token (*If`AUTH_TOKEN`\n\nsecret).`CLIENTS`\n\nis unset, the`AUTH_TOKEN`\n\nsecret enables a single shared bearer token (constant-time comparison); the deployed gateway has it enabled. Without it the gateway runs open (dev mode). The PoC worker remains open.*Per-client rate limiting (opt-in, requires per-client mode).*When per-client mode is active, the client's`rpm`\n\nis a non-null number, and the`RATE_LIMITER`\n\nDurable Object binding is present, each`POST /mcp`\n\nis counted against a**fixed window** of 60 s persisted in the DO's SQLite-backed storage (one DO instance per`client_id`\n\n, keyed by name). Within quota, responses carry`X-Gw-RateLimit-Limit`\n\n/`-Remaining`\n\n/`-Reset`\n\n;`Remaining`\n\ncounts**including the current request**(the admitted sequence shows`rpm, rpm-1, … 1`\n\n). Over quota the gateway returns`429`\n\nwith`Retry-After`\n\nand`Remaining: 0`\n\n. Honest edges: a fixed window allows a burst of up to`2 × rpm`\n\nstraddling a window boundary (a client can spend a full window's quota at its tail and the next window's at its head), and the limiter is per-request, not per-cost — it caps call count, not payload size, CPU, or complexity. If the DO itself fails while the limiter is active, the gateway**fail-closes** with an observable`500 rate_limiter_unavailable`\n\nrather than letting the request through uncounted. Without the binding (or with no`rpm`\n\n), the limiter stays inactive and the request path is byte-identical to the prior behavior.\n\n**Per-skill isolation is context-level, not process-level.** Skills get separate QuickJS contexts but share the same wasm module instance and the same Worker request; the boundary is the QuickJS API surface, not an OS process.**One asyncify suspension at a time — per module instance.** QuickJS asyncify suspends/resumes a single stack per wasm instance; within one request all execution is sequential on its instance. Cross-request parallelism comes from the instance pool (up to`WASM_POOL_SIZE`\n\nconcurrent requests per isolate), not from overlapping suspensions on one instance.**State is in-memory and per-request.** No persistence, no warm state between requests. A tool that accumulates state loses it when the request ends.**DoS is bounded, not impossible.** The limits above cap a single call's cost; a determined caller can still spend the limits' worth of CPU/memory per request. Discovery is cached in two layers: layer 1 caches the parsed result per isolate for 60 s, and layer 2 caches the full post-verification result in the Cache API per colo for 60 s (observable via the`X-Gw-Discovery: hit|l2|miss`\n\nresponse header —`hit`\n\nserved from layer 1,`l2`\n\nhydrated cross-isolate from layer 2,`miss`\n\nfetched from the origin). The layer 2 key carries a config fingerprint (attestation mode + reviewer registry + UTC date), so changing the config never serves stale verdicts. What is cached is post-verification (the`tool.js`\n\nbytes were already hash-checked when layer 2 was populated), inside the account's own trust domain; the cold path is amortized more, but still not zero. A scheduled preheat (cron every minute,`[triggers]`\n\nin`wrangler-gateway.toml`\n\n) runs discovery for every allowlisted origin and instantiates a wasm module, so the cron's isolate/colo rarely serves a cold miss — honest caveat: the Cache API is per-colo and the cron runs in one location, so other colos still pay their first miss.**The publisher is trusted for the skill list.** The gateway trusts the origin's`/llms.txt`\n\nto name skills; it verifies the`tool.js`\n\nbytes match the declared SHA-256, but it does not vet what the tool does.\n\n| File / dir | Purpose |\n|---|---|\n`CHANGELOG.md` |\nPer-release changes of the published npm package (verified against the actual tarballs). |\n`host.mjs` |\nSynchronous `ToolHost` : loads `tool.js` into QuickJS-wasm, injects the `host.callInternal` capability. |\n`host-async.mjs` |\n`AsyncToolHost` : asyncify variant, async handlers, `host.fetchOrigin` capability, the `extraCapabilities` bridge (`host.memorySearch` ), mem/stack/gas hardening. |\n`mcp-core.mjs` |\nSync MCP JSON-RPC 2.0 core (`initialize` , `tools/list` , `tools/call` , `ping` ). Transport-agnostic. |\n`mcp-core-async.mjs` |\nAsync MCP core; awaits `AsyncToolHost.callTool` . |\n`worker.mjs` |\nPoC MCP server (sync host, inline tools) deployed at `toolhost-mcp.rckflr.workers.dev` . |\n`worker-spike.mjs` |\nAsync spike (fetch_home/fetch_evil) proving origin-scoped fetch. |\n`worker-gateway.mjs` |\nThe gateway: discover → verify → load → serve MCP, + origin-memory injection and attestations. Deployed at `llmstxt-gateway.rckflr.workers.dev` . |\n`llmstxt-parse.mjs` |\nPure parser for the executable-skill lines (and the `skills-memory` line) of `llms.txt` . Also reports prose-only (`nonExecutable` ) skills found in `## Skills` . |\n`sigstore-attest.mjs` |\n`verifySigstoreAttestation` / `buildSigstoreStatement` — Sigstore (keyless) attestation verification. Node-only (see \"Sigstore attestations\" above); used by `bin/mcpwasm-local.mjs` 's `--require-attestation` , not the gateway. |\n`worker-memspike.mjs` |\nMemory spike: docs-site origin served through the gateway with `host.memorySearch` over a BM25 snapshot. |\n`internal-logic.mjs` |\nDemo platform logic for the sync PoC (holds the secret, exposes `createPayment` /`refundPayment` ). |\n`tools-inline.mjs` |\nInline `tool.js` sources for the sync PoC. |\n`shim.mjs` |\n`location` /`self` shim needed by the quickjs-emscripten wasm loader in Workers. |\n`build.mjs` / `build-spike.mjs` / `build-gateway.mjs` |\nesbuild bundlers (conditions `workerd` , external `*.wasm` ) for the PoC, spike, and gateway workers. |\n`build-memspike.mjs` / `build-memsnapshot.mjs` |\nesbuild bundler for the memspike worker, and the snapshot builder for the docs-site BM25 snapshot. |\n`mf-test.mjs` / `mf-spike.mjs` / `mf-gateway.mjs` / `mf-memspike.mjs` |\ne2e tests with Miniflare v4 against the built workers (PoC, spike, gateway, memspike). |\n`wrangler.toml` |\nWrangler config for the PoC (sync) worker. |\n`wrangler-gateway.toml` |\nWrangler config for the gateway. Vars: `ALLOWED_ORIGINS` (origin allowlist), `REVIEWERS` (attestation reviewer registry, JSON), `ATTESTATION_MODE` (`off` /`advisory` /`enforcing` ). Service bindings `DEMO` , `BOOKSTORE` , `DOCS` (same-account worker-to-worker fetch, bypassing Cloudflare error 1042). `AUTH_TOKEN` and `CLIENTS` are set as secrets, not in this file. Durable Object binding `RATE_LIMITER` (class `RateLimiter` , migration `v1` with `new_sqlite_classes` ) deploys with the worker; the limiter stays inactive until a `CLIENTS` registry with `rpm` values exists. |\n`scripts/attest.mjs` |\nAttestation tool: `keygen` (writes local `.attester-key.json` , prints public key) and `sign <origin> <skill> <valid_until>` (Ed25519 attestation JSON). |\n`bench/` + `BENCHMARK.md` |\n`bench/run.mjs` (single-client latency harness against the deployed workers) and its raw results; `BENCHMARK.md` is the write-up. |\n`quickjs.wasm` / `quickjs-asyncify.wasm` |\nPre-compiled QuickJS binaries imported as static `CompiledWasm` modules. |\n`minimemory_bg.wasm` |\nPre-compiled minimemory (BM25) wasm, the engine behind `host.memorySearch` . Imported as a static `CompiledWasm` module by the gateway. |\n`demo-site/` |\nDemo publisher site (`llms.txt` + `sum_numbers` / `server_time` skills). Deployed at `llmstxt-demo-site.rckflr.workers.dev` . |\n`bookstore/` |\nRealistic publisher: D1-backed catalog (52 books), read skills + `create_order` write skill, plus permanent robustness fixtures (`corrupt_skill` hash-mismatch, `busy_loop` infinite loop). Deployed at `llmstxt-bookstore.rckflr.workers.dev` . |\n`docs-site/` |\nDocs publisher: serves the llms-txt-skills spec documents + a `skills-index.snapshot` (BM25, `minimemory-okf-v1` ), with `search_spec` (BM25 via `host.memorySearch` ), `get_doc` , and `list_docs` skills. Deployed at `llmstxt-docs.rckflr.workers.dev` . |\n`reports/` |\nDevelopment reports, one `TAREA*-REPORT.md` per milestone (see below), plus the raw MCP-client outputs of T13-T15. |\n`.github/workflows/ci.yml` |\nGitHub Actions CI: two jobs (`hermetic` gate + `prod-integration` non-blocking) on push and pull_request to `main` . |\n\nThe workflow in `.github/workflows/ci.yml`\n\nruns two jobs on every push and\npull_request to `main`\n\n, both on `ubuntu-latest`\n\nwith Node 22 and `npm ci`\n\n(with cache), timing out after 15 minutes.\n\nThe `hermetic`\n\njob is the gate. It runs five local suites — `npm test`\n\n,\n`npm run spike`\n\n, `npm run memspike`\n\n, `npm run gateway:offline`\n\n, `npm run local`\n\n— each preceded by its own build. None of these touch the network\nbeyond `npm`\n\nitself: `test`\n\n, `spike`\n\n, `memspike`\n\n, and `local`\n\nare fully local\n(the last spawns the stdio runtime against an in-process fake publisher on\n`127.0.0.1`\n\n), and `gateway:offline`\n\nis the hermetic mode of the gateway suite\n(T35), where the production workers are replaced by in-process fakes served\nthrough the same URL-to-binding map the gateway uses. Hermeticity is enforced\nby an outbound fetch interceptor: if anything in the suite tries to leave the\nprocess for the network, the run\nfails. This job blocks the merge.\n\nThe `prod-integration`\n\njob runs `npm run gateway`\n\n, the online gateway suite\nagainst the deployed production workers (`*.rckflr.workers.dev`\n\n) over the\npublic internet. This is the only command in CI that reaches production, and\nits purpose is to detect drift between the fakes and the real workers. It is\nnon-blocking (`continue-on-error`\n\n): an outage on their side surfaces as a\nwarning, not a red gate, so a foreign incident cannot block work in this repo.\n\nEach milestone is documented in its `reports/TAREA*-REPORT.md`\n\n(TAREA1 through TAREA45;\n`TAREA2`\n\nand `TAREA30`\n\nwere skipped in numbering and `TAREA12B`\n\nis a\ncontinuation of TAREA12).\nThe non-obvious bits live there:\n\n`reports/TAREA4-REPORT.md`\n\n— deploying to Cloudflare Workers: the`CompiledWasm`\n\nrule and why importing the`.wasm`\n\nas a static module avoids \"Wasm code generation disallowed by embedder\".`reports/TAREA5-REPORT.md`\n\n— the asyncify spike: why asyncify is needed for an`await`\n\n-shaped capability, and the promise-pumping loop in`AsyncToolHost.callTool`\n\n.`reports/TAREA7-REPORT.md`\n\n— the gateway: sha256 verification, the Cache API use, and the Cloudflare error 1042 (same-account worker-to-worker fetch via`workers.dev`\n\n) workaround via a service binding.`reports/TAREA12-REPORT.md`\n\n/`reports/TAREA12B-REPORT.md`\n\n—`Date.now()`\n\nis frozen in Cloudflare Workers during synchronous execution, so a wall-clock deadline never cuts a`while(true){}`\n\n. Fix: a deterministic gas budget — the interrupt handler counts its own invocations and interrupts at 20 000, independent of the clock. Calibrated against the heaviest legitimate skill.`reports/TAREA14-REPORT.md`\n\n—`structuredContent`\n\nin an MCP result must be a JSON object (MCP-shaped), not a bare scalar/array; the gateway normalizes tool output accordingly.`reports/TAREA19-REPORT.md`\n\n— concurrency: a per-wasm-module mutex on instantiation plus single-flight discovery per origin, so parallel cold requests share one discovery pass and one module build.`reports/TAREA22-REPORT.md`\n\n— origin memory: the`skills-memory`\n\nline, sha256-verified BM25 snapshot, and the`host.memorySearch`\n\ncapability injected via`extraCapabilities`\n\n.`reports/TAREA25-REPORT.md`\n\n— skill attestations (Ed25519, WebCrypto,`REVIEWERS`\n\nregistry, verdicts, advisory/enforcing modes,`scripts/attest.mjs`\n\n).`reports/TAREA26-REPORT.md`\n\n— code-review fixes:`extraCapabilities`\n\nnow forwards all positional args (so`host.memorySearch(q, k)`\n\nkeeps`k`\n\n), and the`fetchOrigin`\n\ntimeout backstop timer is cleared on resolve (no leaked timers).\n\nBenchmark headline numbers (full matrix and methodology in\n[ BENCHMARK.md](/MauricioPerera/mcpwasm/blob/main/BENCHMARK.md), single-client from México to the Workers\nedge, not a load test; latest figures from the post-pool+preheat run):\nthe sandbox itself costs\n\n**~2 ms warm**(gateway pure-sandbox\n\n`sum_numbers`\n\np50 ≈ 55 ms vs. the same worker's raw ping p50 ≈ 53 ms), and the full gateway\nadds **~6 ms** over calling the publisher's API directly for the same read (\n\n`stock_report`\n\nthrough the gateway p50 = 96 ms vs. direct API p50 = 90 ms).\nA cold discovery miss costs ~210–400 ms (compile + sha256 + fetch); the\nscheduled preheat (see \"Security model\" above) keeps the cron's own\nisolate/colo mostly out of this cold path.Run the e2e tests with `npm test`\n\n(sync) / `npm run spike`\n\n(async) /\n`npm run gateway`\n\n(gateway against the live demo site) / `npm run memspike`\n\n(memory capability against the docs-site origin).", "url": "https://wpnews.pro/news/show-hn-static-mcp-publish-mcp-tools-as-static-files-run-them-sandboxed", "canonical_source": "https://github.com/MauricioPerera/mcpwasm", "published_at": "2026-07-27 03:08:12+00:00", "updated_at": "2026-07-27 03:22:17.135699+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-infrastructure"], "entities": ["Static MCP", "mcpwasm", "QuickJS-wasm", "Cloudflare Workers", "llms-txt-skills", "@rckflr/mcpwasm", "Claude Code", "Cursor"], "alternates": {"html": "https://wpnews.pro/news/show-hn-static-mcp-publish-mcp-tools-as-static-files-run-them-sandboxed", "markdown": "https://wpnews.pro/news/show-hn-static-mcp-publish-mcp-tools-as-static-files-run-them-sandboxed.md", "text": "https://wpnews.pro/news/show-hn-static-mcp-publish-mcp-tools-as-static-files-run-them-sandboxed.txt", "jsonld": "https://wpnews.pro/news/show-hn-static-mcp-publish-mcp-tools-as-static-files-run-them-sandboxed.jsonld"}}