com.thecryptoport/cryptoport-port
OfficialREADME.md
# The Crypto Port — MCP server
**What crypto YouTube channels said, as a daily board — coin calls, stances, dated
catalysts — served over MCP. Free, no API key.**
- **Live endpoint (streamable HTTP):** `https://cryptoport-mcp.cryptoport-port.workers.dev/mcp`
- **Registry name:** `com.thecryptoport/cryptoport-port` (official MCP registry, registry.modelcontextprotocol.io)
- **Website (the human board):** <https://thecryptoport.com>
## Add to your client
Claude Code:
```bash
claude mcp add --transport http cryptoport https://cryptoport-mcp.cryptoport-port.workers.dev/mcp
```
Any MCP client that speaks streamable-HTTP remotes:
```json
{
"mcpServers": {
"cryptoport": {
"type": "streamable-http",
"url": "https://cryptoport-mcp.cryptoport-port.workers.dev/mcp"
}
}
}
```
**On the live endpoint above, all six tools are free** — `latest_board`, `search`, `coin`,
`stance_history`, `catalysts`, `board_snapshot`. That deployment has no `payTo`, so no payment
middleware is mounted and nothing can be charged; its own `/healthz` and `/.well-known/mcp.json`
say so. The prices in the table further down belong to the *paid* x402 port (Base Sepolia
testnet) and apply only to a deployment configured with a `payTo`.
Everything served is commentary on public statements by named channels; every payload carries
`stale` / `stale_days` / `built_from_brief_date` — read them. Not financial advice, not
real-time.
---
## Build context — the rung-3 machine port
MCP server + x402-payable HTTP endpoints over thecryptoport.com's daily board.
**Network: Base Sepolia (`eip155:84532`) — testnet, throughout. No mainnet sign-off exists and
none is assumed. Nothing is charged in real money on any network this server talks to.**
Master's ruling of record for this build is **C+ (2026-08-24)**: build the paid-port mechanics
now on testnet only; the paid tier itself stays declined and the mainnet switch is the day-60
ruling. The prices below exist so the payment handshake is real, not because anyone is billed.
Runtime owner: **Dex**. Ward wrote the money, screening and licensing logic; Dex stands the
process up and keeps it alive. See "Handoff to Dex" below.
---
## What it is
| Tool | Price (testnet USDC) | HTTP twin |
|---|---|---|
| `latest_board` | free | `GET /api/v1/board` |
| `search(q, limit)` | free | `GET /api/v1/search?q=` |
| `coin(ticker)` | 0.005 | `GET /api/v1/coin/:ticker` |
| `stance_history(ticker)` | 0.01 | `GET /api/v1/stance_history/:ticker` |
| `catalysts(window)` | 0.01 | `GET /api/v1/catalysts?window=` |
| `board_snapshot(date)` | 0.02 — **not charged today** | `GET /api/v1/board_snapshot/:date` |
Free meta: `GET /healthz`, `GET /x402/routes` (the price table), `GET /.well-known/mcp.json`.
MCP: `POST /mcp` (streamable HTTP) and `npm run mcp-stdio` (stdio).
**The MCP tools are thin proxies onto the HTTP twins inside the same process.** One code path for
pricing, screening, refusal and the published-field gate, so the two surfaces cannot disagree.
### What the paid tier actually sells — read this before pricing anything
`llms.txt` says, in the site's own words: *"there is no hidden paid tier of the same rows."*
That sentence is a constraint on this build, and it holds: every paid payload names the free
static file the same facts live in (`upstream`). What is priced is **form**, not exclusivity —
a parsed dated series instead of an `arc` string, a windowed catalyst query instead of a full
file. If a future paid tool ever returns a fact the free tier does not, that llms.txt line
becomes false and has to change first. → flagged to Larry, not decided here.
---
## Run it
```bash
npm install
npm run sdn-refresh # refresh the OFAC list (vendored copy already present)
node scripts/keygen.js # writes .env.sepolia (mode 600); prints only the address
X402_PAY_TO=0x… npm start # http://localhost:8402
npm test # 91 tests
npm run demo # free call -> 402 -> (pay) -> data + receipt
```
Data comes from the rung-0 emitter: `CRYPTOPORT_DATA_DIR`, else
`<repo>/../../Documents/Claude/myPKA/infra/cryptoport-site/dist`, falling back to `staging`
when `dist` has no `feed.json`. It is read **once at boot** into memory — the request path never
touches the filesystem, which is what makes `src/worker.js` (Cloudflare Workers) a deployment
choice rather than a rewrite.
### Claude Code MCP config (stdio)
```json
{ "mcpServers": { "thecryptoport": {
"command": "node",
"args": ["/home/ops/projects/cryptoport-port/src/mcp-stdio.js"],
"env": { "X402_PAY_TO": "0x…" }
} } }
```
Streamable HTTP instead: point the client at `http://localhost:8402/mcp`.
---
## Mechanism chosen, and what was rejected
**Chosen:** x402 v2 middleware in front of the paid routes + the free public testnet facilitator
+ USDC on Base Sepolia. **No smart contract, no bytecode of any kind.** A price, a receive
address and a verifier are the whole mechanism, and fewer contracts is a security property.
| Rejected | Why |
|---|---|
| A bespoke access/subscription contract | Pure audit surface for something x402 does natively per call. |
| A self-hosted facilitator | Right idea, wrong scale. Buys decentralisation we do not need and adds a service to keep alive. |
| API keys + Stripe | Reintroduces accounts, PII and an operator in the loop; wrong shape for anonymous agents. |
| Any token, subscription NFT or fractionalised instrument | Pre-CLARITY securities exposure for zero benefit. Hard no. |
| `@x402/paywall` (a peer dep of `@x402/hono`) | Installs **716 packages** including WalletConnect, for a browser paywall UI no agent will ever see. `@x402/hono` works without it; verified. |
Header names are read out of `@x402/core`'s **v2** code paths (corrected 2026-09-05 — the 8/24
build had the v1 pair): request **`PAYMENT-SIGNATURE`** (v1 `X-PAYMENT` still accepted and still
screened), receipt **`PAYMENT-RESPONSE`** (`X-PAYMENT-RESPONSE` read as a fallback only), terms in
**`PAYMENT-REQUIRED`** on the 402 (base64 JSON) — repeated in the JSON body so an agent does not
have to base64-decode a header to learn a price, and handed to MCP callers verbatim in `x402`.
`test/x402.test.js` asserts these names against the SDK's own functions. Package generation matters: the scoped `@x402/*` v2
packages are pinned in `package.json` and coexist on npm with the v1 `x402-hono`/`x402-next`.
**No license is signed and no license hash is referenced here.** This rung sells access to a
compilation of public statements; there is no geometry, no licensable work product, and no
EIP-712 typed-data license in the design. The `license` string in every payload is rung 0's own
attribution notice, passed through untouched.
---
## Gates (all three run before anything is served)
1. **Advisory-input refusal — `src/inputs.js`.** Any argument named like portfolio / holdings /
position / allocation / balance / risk profile / cost basis / PnL / leverage / stop-loss /
"should I buy" is refused with **400** and a plain reason, on HTTP and MCP alike, *before*
payment is even considered. Values are never inspected — we refuse the shape, we do not read
the data. Every tool description says so.
2. **OFAC SDN denied-party screening on the payer — `src/sdn.js`.** A local check of every
`0x…` address found anywhere in the decoded `X-PAYMENT` payload against OFAC's published SDN
digital-currency address list (991 addresses, 124 EVM, published 08/24/2026), fetched
read-only by `scripts/sdn-refresh.js` from OFAC's own endpoint and vendored as
`data/sdn-addresses.json`. A listed payer gets **403**, is never settled and is never served.
*Not* KYT: it screens the paying address, it does not trace funds. No KYC, no accounts, no PII
— collecting identity for a $0.005 data call creates a worse surface than it mitigates.
3. **B-2 published-field gate — `src/allowlist.js`.** `quality`, `counterweight`, `rank` and
`presence` never leave the process, however they are spelled, and any *undeclared* key raises
too. The held-back list has one source of truth —
`infra/cryptoport-site/allowlist.py` — and `test/allowlist-parity.test.js` fails if the two
drift. Upstream rows are projected on load, so a widened emitter cannot silently widen this
surface.
**One decision recorded in that gate (2026-09-07), because a silent drop is still a decision.**
`projectRung0()` drops undeclared upstream keys rather than serving them — correct by default,
but it had been quietly dropping `catalyst_refs` from every coin file. Those are the D19 pointers
(`claim_id` + `due` + the SUBJECT ticker's `canon`, never the description) to claims that merely
*name* this coin, so one claim's text is stored and served exactly once. Dropping them made the
`coin` tool's own advertised sentence false: it promises "the same facts are also free as static
JSON at `/coins/<TICKER>.json`" while serving strictly **less** than that file, and an MCP
consumer saw a different coin shape than the origin. **Decided: declare, do not drop.** Nothing
new leaves the DB — every key inside a ref is already published, and the descriptions the refs
point at are already served whole by the free `catalysts` tool. Pinned by two tests in
`test/tools.test.js` ("catalyst_refs survives the rung-0 projection" and "the coin tool serves the
origin's catalyst_refs, not a narrower shape"). **Code reaches a Worker only when THAT Worker is
deployed** — there are two (free `cryptoport-mcp`, paid `cryptoport-x402`), each deployed by its
own command, and the daily KV push refreshes the board, never the code. Current state of both:
"Deploy status" under the demand-counter section at the end.
*Correction to the message of commit `06e299f`:* it says an agent that used the descriptor's wrong
`days` argument had its call **rejected**. It did not. Nothing validates a call against the
advertised schema on either transport, so `days` was silently ignored — verified live 2026-09-07,
`{"days":7}` returns `isError:false` with all 40 catalysts (10 past due) and `window_days:null`,
where `{"window":7}` returns 2. Silently wrong, not an error the caller could see. The commit
message cannot be edited without a history rewrite; this line is the record.
**Proof the gates can fail** (the point of having them):
`test/sdn.test.js` refuses a *genuinely OFAC-listed* address end-to-end over HTTP, then removes
that one address from the list and asserts the same call is **not** refused — so the 403 is
demonstrably coming from the gate and not from somewhere else. `test/b2-leak.test.js` injects
each held-back column into the upstream JSON and asserts every tool either raises or projects it
away, and that the HTTP surface answers 500 with no leaked value in the body.
---
## Wallet surface
* **One receive-only address** as `payTo`. It signs nothing, holds no approvals, calls no
contract. The server does not need its key and **refuses to start** if any
`*PRIVATE_KEY` / `*RECOVERY_KEY` / `MNEMONIC` / `SEED_PHRASE` variable is present in its
environment (`test/network-guard.test.js`).
* **Testnet throwaway EOA**, per the `~/projects/cad-on-chain/scripts/keygen.js` pattern, with
one change: **the key is written straight to `.env.sepolia` (mode 600) and never printed.**
stdout ends up in terminals, transcripts and session logs, and "no private key in a repo, a
note or a log" has no testnet exemption.
* **It does not graduate.** A mainnet `payTo` is a CDP-managed Server Wallet or it does not exist.
* Chain id 84532 is hard-guarded and every mainnet id is denylisted by name.
* Non-custodial by construction: no buyer funds, no buyer keys, no buyer files, no refunds
(settlement precedes delivery), and **no file custody, ever**.
---
## Mainnet gate list (verbatim from the Lane C spec §C7 — all must be true, none inferred)
1. Master's explicit **per-deployment** mainnet sign-off, quoted verbatim in the deliverable.
2. Paid-tier ruling reopened and granted.
3. Pax/Master answers on §C5 (a)–(d), especially the investment-adviser question.
4. `payTo` is a CDP-managed account; **no key material anywhere on disk**; the throwaway-EOA
pattern retired.
5. SDN denied-party gate demonstrated **rejecting** a listed address.
6. B-2 leak test green in CI, and demonstrated failing when a held-back column is introduced.
7. Facilitator switched from `x402.org` (testnet-only) to CDP with credentials held outside the
repo.
8. Ownership/receipt question settled on a domain that is not Master's.
9. No new bytecode — if that changes, Slither + Foundry fuzz/invariant in CI **and** a named
external audit before mainnet.
Status against that list today: **5 and 6 are done and demonstrated. 1, 2, 3, 4, 7 and 8 are
untouched.** 9 holds — this build ships no bytecode, so no Slither/Foundry pass and no external
audit is required; if that ever changes, item 9 applies in full.
---
## Handoff to Dex (runtime requirements, not a runtime decision)
* **Uptime.** It must survive a reboot unattended, with no permanent operator clicks. On the box
that means a systemd unit — **gated live config → Master**. Ward did not create one.
* **Secrets.** The process needs exactly one variable: `X402_PAY_TO`. It needs **no key**. Hold
it outside any repo. `.env.sepolia` / `.env.buyer` are mode-600 and git-ignored and the server
never reads them.
* **Cold start.** The whole board is read once at boot (~60 JSON files, tens of ms). On Workers,
the same shape comes from KV via `src/worker.js` (`env.BOARD` keys: `feed`, `coins`,
`catalysts`, `sdn`), pushed by the 07:50 cron. Both Workers (free and paid) are live on the
davitlabs Cloudflare account — see the two sections at the end.
* **Outbound HTTPS** to `https://x402.org/facilitator` on every paid call (1–2 subrequests). The
datacenter-egress problem on this box is inbound reputation at YouTube, not outbound HTTPS.
* **Data.** Point `CRYPTOPORT_DATA_DIR` at whatever the 07:50 job emits. The port re-reads only
at boot; restart it after the emit, or add a reload.
* **SDN refresh** on the same cron: `npm run sdn-refresh` (read-only fetch from OFAC, ~30 s).
* **Rate limiting** on the free tier is an edge concern → Dex. The paid tier is self-limiting.
* **Anchoring stays rung 1 → Dex.** The port consumes anchors and passes them through untouched;
it never re-anchors, re-hashes or forks the passport record. **Note:** the current `dist` build
carries `anchor.root = null` while `staging` has real Merkle roots — the deployed build is
un-anchored. The server prints a warning at boot and serves the empty anchor faithfully. That
is Dex's to fix, not Ward's.
* **Registry submission — DONE.** Listed as `com.thecryptoport/cryptoport-port` on
registry.modelcontextprotocol.io via HTTP domain proof (root `server.json`,
`scripts/registry-publish.sh`).
## Cost
**Expect ~$0.** Free testnet facilitator, free registry listing, testnet USDC, existing hosting,
no contract, no vendor. The only recurring cost that ever appears is CDP's $0.001 per settlement
above 1,000/month at mainnet, which at any realistic volume for this board is zero. Revenue
expectation is also ~$0: at the ecosystem median a *thousand* paid calls a month is about $28;
at these prices, $5–10. This lane is a demo of the stack and an option on a rail.
## Known limitations and confidence flags
* **Bazaar discovery does not work on testnet — CONFIRMED 2026-08-24.** The free
`x402.org` facilitator's `/supported` advertises
`['builder-code','eip2612GasSponsoring','erc20ApprovalGasSponsoring']` — no `bazaar` — and
`GET /facilitator/discovery/resources` returns **404**. Our 402s carry a valid `bazaar`
declaration (`registry/bazaar-mcp.json`, and `test/x402.test.js` asserts it), but nothing
indexes it. A real Bazaar listing needs the CDP facilitator, which needs an account. This
contradicts the "Bazaar day one" line in the Lane C spec §C3 and is a mainnet-gate item.
* ~~The demo stops at the 402~~ — **settled 2026-08-27 locally and 2026-09-05 on the hosted
Worker** (three 0.01-USDC settles on Base Sepolia, HTTP and MCP legs; see the paid-Worker
section below). The buyer holds 0.255 test USDC for future runs.
* **MCP registry manifest** is the root `server.json`; it validates against the live registry
schema and is what `scripts/registry-publish.sh` publishes. (The earlier unused draft at
`registry/server.json` was removed 2026-08-30.)
* **`board_snapshot` is a stub** and is deliberately **not charged** while it is one: no anchored
historical board series exists yet.
* **Settlement metadata arrives in the `PAYMENT-RESPONSE` header**, not the response body,
because the SDK settles after the handler has already written the body. The MCP layer merges
it into the payload as `settlement`; HTTP callers read the header. Documented rather than
worked around.
* **`stance_history` derives from the published `arc` string.** If rung 0 ever stops publishing
`arc`, this tool has nothing to parse.
* Ward writes no client-facing copy. The `disclaimer` sentence in every payload is engineering
boilerplate about what an anchor does and does not prove — **Rhea/Pax to ratify the wording**.
## Open questions for Pax / Master (flagged, not decided)
Unchanged from the Lane C spec §C5: (a) does charging for a summarised feed of named channels'
calls change the attribution / derivative-work posture; (b) is a paid crypto-calls feed exposed
to investment-adviser framing that the free board is not; (c) money-transmission characterization
of receiving USDC for data; (d) may the seven channel names appear in a *paid* product. None of
these are answered, and this build charges nobody.
## Files
```
src/config.js env + the chain-id guard + the no-keys-in-env rule
src/allowlist.js B-2 gate (mirrors infra/cryptoport-site/allowlist.py)
src/data.js snapshot, freshness, arc parsing, search — pure, Workers-safe
src/inputs.js advisory-input refusal
src/sdn.js OFAC SDN screening
src/tools.js the one tool table: prices, schemas, descriptions, handlers
(its advertised half is mirrored in the origin's mcp-tools.json —
test/descriptor-parity.test.js fails if the two ever drift)
src/x402-routes.js route config, 402 body, Bazaar declarations, price table
src/app.js Hono app: gates, paywall, HTTP twins, free meta
src/mcp.js MCP server + streamable-HTTP mount (proxies the HTTP twins)
src/mcp-stdio.js stdio transport entry
src/node-server.js Node entry: disk -> memory at boot
src/worker.js Cloudflare Workers entry (KV-backed; no account created)
src/stats.js demand counter: counts per tool/transport/day in KV (/stats + /healthz.demand only if STATS_PUBLIC=1)
src/evm-keys.js keygen/address, same pattern as cad-on-chain
scripts/ keygen, sdn-refresh, balance, demo, gen-manifests, kv-push,
stats-read (the operator's read-only view of the private counts)
registry/ server.json (MCP registry), bazaar-mcp.json
data/ sdn-addresses.json (vendored OFAC export)
test/ 91 tests
```
---
## Free MCP server on Cloudflare Workers (deployed 2026-08-27)
**Live:** `https://cryptoport-mcp.cryptoport-port.workers.dev/mcp` — MCP over HTTP,
six tools, **free**, no key and no account.
Master ruled on 2026-08-27 that the free MCP server ships before the paid x402 port
is hosted: it is the "free front door onto a human budget" from the strategy memo,
it is the only thing that produces the visitor number the day-30 checkpoint needs,
and the descriptor the site publishes at `/.well-known/mcp.json` had been naming
tools with no server behind them.
**Free by construction, not by policy.** `wrangler.jsonc` sets no `X402_PAY_TO`, so
`createApp` never mounts the x402 middleware, `isCharging()` reports `price_usdc: "0"`
and `paid: false` on every response, and no tool description quotes a cost. Master's
ruling 4a — paid tier declined until the day-60 checkpoint — is therefore enforced by
the absence of a variable rather than by anyone remembering it.
⛔ **Do not add `X402_PAY_TO` to this Worker.** The paid port is a separate deployment
whose hosting was deferred; turning payments on here would flip a declined tier by
editing a config file.
**Data path.** `scripts/kv-push.mjs` reads the built `dist/` and writes `feed`,
`coins`, `catalysts` and `sdn` into the `BOARD` KV namespace. The Worker reads KV; it
never touches a filesystem and never calls back to Bluehost — which also means machine
clients that Bluehost's mod_security rejects (`python-requests` gets a 406 there) can
reach the board here.
**No zone, no DNS.** It answers on the account's own `*.workers.dev` subdomain. No
record on thecryptoport.com or any other portfolio domain was created or changed, which
is what keeps this clear of the never-touch mail directive on those zones.
### Daily refresh — INSTALLED 2026-08-27 on Master's sign-off
KV holds whatever was last pushed, so the push rides the end of the existing 07:50
chain — it runs only if the build, the allowlist gate and the deploy all succeeded.
**Dry-run it under cron's environment before ever changing this line.** wrangler's
shebang is `#!/usr/bin/env node`, and cron's minimal PATH has no `node`: the first
attempt died with exit 127, the same trap that killed the channel_intel pipeline on
2026-08-15. `kv-push.mjs` now extends PATH with its own `process.execPath` directory.
The check that catches it:
```sh
env -i HOME=/home/ops PATH=/usr/bin:/bin SHELL=/bin/sh \
/home/ops/.nvm/versions/node/v24.15.0/bin/node scripts/kv-push.mjs
```
Operator notes (host, deploy chain) live outside this repo.
Run it by hand any time: `node scripts/kv-push.mjs`.
---
## Paid x402 port on Cloudflare Workers — TESTNET (deployed 2026-09-05)
**Live:** `https://cryptoport-x402.cryptoport-port.workers.dev` — the same code as the free
Worker, deployed from **`wrangler.x402.jsonc`** with `X402_PAY_TO` set, so the four priced tools
answer **402** with x402 v2 terms for **Base Sepolia (`eip155:84532`, test USDC)** and settle
through the free `x402.org` facilitator. `latest_board` and `search` stay free; `board_snapshot`
is unpriced while it is a stub. MCP at `POST /mcp` (same Worker) says `COSTS n USDC on Base
Sepolia` on the priced tools. Nothing is charged in real money; ruling 4a stands. Readout with
the recorded demo, on-chain receipts and the Bazaar staging:
`myPKA/Deliverables/2026-08-24-thecryptoport-strategy/12-ward-x402-port-hosted-testnet-2026-09-05.md`.
```bash
npx wrangler deploy --config wrangler.x402.jsonc # deploy the PAID worker
npx wrangler deploy # deploys the FREE worker (wrangler.jsonc) — separate
PORT_URL=https://cryptoport-x402.cryptoport-port.workers.dev npm run demo # free -> 402 -> pay -> receipt, then the MCP leg
```
**Why a second Worker and not an `env` block:** `wrangler.jsonc` says its *absence* of
`X402_PAY_TO` is what enforces the declined paid tier. A paid environment inside that file would
put the declined tier one config line away. Two files, two Workers, no shared switch.
`X402_PAY_TO` is a plain `var` — it is on every 402, so it is not a secret; **no `wrangler
secret` exists on either Worker.**
**Three things the live host taught that the suite could not (all fixed, all tested):**
1. **Do the facilitator handshake yourself, awaited.** `@x402/hono` starts
`httpServer.initialize()` at construction and awaits it only on the first *paid* request.
Workers cancel I/O that is still in flight when a response goes out, so an isolate whose
first request was free hung every paid request forever (`wrangler tail`: `outcome: canceled`,
~0 ms CPU, 25 s wall). `src/worker.js` now passes `syncFacilitatorOnStart:false` and awaits
`app.__x402.initialize()` inside `build()`, caching the isolate only on success.
2. **The hosted SDN list was empty.** `kv-push.mjs` read `data/sdn.json` — a file that never
existed — and pushed `{"addresses":[]}`. Now: the right file, a refusal to push < 100
addresses, a bundled fallback list in `worker.js`, a **fail-closed** guard in `app.js` (payTo
set + empty screener → refuse to build), and `/healthz.sdn` so the loaded gate is visible.
3. **v2 header names.** Clients send `PAYMENT-SIGNATURE`; the gate read only `X-PAYMENT`, so a
real buyer was never screened, `payer` was missing and the `PAYMENT-RESPONSE` receipt read as
null. Every gate now reads what the middleware reads; `test/paid-path.test.js` signs a real
v2 payload with an ephemeral in-memory key and settles it through the stub facilitator.
**Cosmetic, documented:** every isolate build logs `x402: Route "…" has an invalid bazaar
extension: … Code generation from strings disallowed` — the Bazaar extension validates schemas
with Ajv (`new Function`), which Workers forbid. Declaration-time only; the 402 still carries
the full `bazaar` block and settlement is unaffected.
**Bazaar + registry — staged, not submitted.** `x402.org` has no Bazaar (re-verified: no
`bazaar` in `/supported`, discovery 404). CDP's facilitator answers **401** unauthenticated, so
a listing needs a CDP account + API key as Worker secrets → Master's gate. When opened:
`X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402`, `wrangler secret put
CDP_API_KEY_ID` / `CDP_API_KEY_SECRET`, and in `app.js`
`new HTTPFacilitatorClient({ url: cfg.facilitatorUrl, createAuthHeaders })` with CDP's JWT
header builder — then one paid call echoes the extension and the four routes appear at
`GET …/x402/discovery/resources?payTo=0xcceb…`. `registry/server.x402-staged.json` is the
0.2.0 registry bump that adds this Worker as a second remote; the published 0.1.1 is untouched.
---
## Demand counter — BUILT 2026-09-23, NOT DEPLOYED — counts PRIVATE by default
**Why.** Until this build nobody had measured `tools/call` by tool on either Worker, and the only
x402 settles on record were paid by our own buyer wallet. Every "agents will find it" argument for
this port rested on a number that did not exist. `src/stats.js` is that number — counts only.
**What is stored, and nothing else.** One KV value per (Worker, UTC day, isolate) in the existing
`BOARD` namespace — **no new binding, no wrangler config change**:
```
stats:<cryptoport-mcp|cryptoport-x402>:<YYYY-MM-DD>:<shard>
{ v, date, deployment,
tools: { <tool>: { mcp|http: { calls, errors, http_402, settled, known_bot } } },
tools_list: { mcp: { calls, known_bot } },
excluded: n }
stats:<deployment>:since the first UTC day this build counted anything
```
**No IP, no payer address, no user agent, no arguments, nothing finer than the day.** The
caller is read once at the edge (`classifyOrigin`) into two booleans — *is this the operator's own
egress* and *does the user-agent family look like a crawler/monitor* (`known_bot`, a coarse regex;
agent runtimes such as node, curl, python-httpx and claude-code deliberately do not match) — and
the IP and UA go no further. `record()` builds the stored doc from enumerated fields only, so an
extra field has nowhere to land. `test/stats.test.js` proves it with sentinel IP / UA / payer /
query values, **and a negative control**: the same check pointed at a store that does leak must fail.
**What counts.** `calls` = every call that reached a tool (MCP `tools/call` or its HTTP twin, counted
once — the MCP layer's in-process call is marked so it is never also counted as `http`);
`http_402` = payment terms issued; `errors` = refused or failed (400 advisory refusal, 403 SDN,
404, 500); `settled` = paid and settled; `served` (computed) = the rest. `tools_list` counts MCP
discovery. Non-tool paths are not counted.
**The operator is excluded — but never in source.** This repo is public, so no IP address is ever
committed to it — not in `src/stats.js`, not in `wrangler.jsonc` / `wrangler.x402.jsonc` (also
committed). `DEFAULT_EXCLUDED_IPS` is an empty array by design; the exclusion list is read ONLY from
the `STATS_EXCLUDE_IPS` env var at deploy time (comma list, exact IPs or IPv4 CIDR, e.g. `203.0.113.7`
or `203.0.113.0/24`). With the var unset, no address is excluded and every call counts. Excluded
calls add only to `excluded`, never to a tool bucket.
Master sets it per Worker — preferred as a **secret** (never appears in `wrangler.jsonc`, this repo,
or `wrangler deployments list`):
```bash
npx wrangler secret put STATS_EXCLUDE_IPS # free Worker (cryptoport-mcp)
npx wrangler secret put STATS_EXCLUDE_IPS --config wrangler.x402.jsonc # paid Worker (cryptoport-x402)
```
or as a plain `--var` at deploy (visible in deployment metadata, but still never committed to a file):
`npx wrangler deploy --var STATS_EXCLUDE_IPS:203.0.113.7` (and the same with `--config wrangler.x402.jsonc`).
The box's egress moves between datacenter ASNs; the current addresses and their history live in
`infra/cryptoport-site/own-egress-ips.txt` in **myPKA** (no remote — never this repo). Re-read that
file and re-set the var when the egress changes.
**KV budget — this account is on the Workers FREE plan.** KV allows 1,000 writes and 1,000 lists
per day **account-wide**, shared with the 07:50 kv-push (4 writes) and the CAD on Chain Worker. A
per-call write would starve the board refresh at ~1,000 calls. So each isolate owns one key per day
(single writer — no read-modify-write, no lost increments under KV's ~60 s cross-colo lag) and
flushes its cumulative totals via `waitUntil`: first ~10 s after an event, then at most every 10 s,
at most every 15 min after 50 writes, hard stop at 150 per isolate per day; a failed write pauses
counting for the rest of the UTC day. Public reads (`/stats`, `/healthz`, only with `STATS_PUBLIC=1`)
share one aggregate per isolate, cached 15 min, with their own list budget; a private Worker spends no
KV list on reads at all. **The result is a floor, not an exact count**, and every
payload says so. Sustained thousands of calls a day is the signal to move this to a Durable Object
or Workers Paid — Master's call, and a good problem.
**The counts are PRIVATE by default (Master's ruling S3.1 = B, 2026-09-23).** Both Workers count
into KV exactly as described above, but with `STATS_PUBLIC` unset **`GET /stats` is not mounted** — it
answers the same `404 {"error":"not_found",…}` as any unknown path, so it does not advertise itself —
and **`/healthz` carries no `demand` block** (not even `enabled:false`). Counting is independent of
this switch: `STATS_ENABLED` alone turns it on or off.
**Read it (operator, from the box, read-only):**
```bash
cd ~/projects/cryptoport-port && node scripts/stats-read.mjs # both Workers, last 14 UTC days
cd ~/projects/cryptoport-port && node scripts/stats-read.mjs --days 30 --only x402 --json
```
It lists and gets the `stats:*` keys from the BOARD namespace named in `wrangler.jsonc` and
`wrangler.x402.jsonc` (today both bind the same one) via this repo's own wrangler, always with
`--remote`, on the box's existing `wrangler login` — the same auth path `scripts/kv-push.mjs` uses. It
prints the aggregate a public `/stats` would serve (per tool, per transport, per day; `--json` gives
the exact payload), built by the same `mergeShard` + `summarize` in `src/stats.js`. **It never
writes**: only `kv key list` / `kv key get` with `--remote` can reach wrangler, and anything else is
refused before a process starts (`test/stats-read.test.js`). One run costs one KV list per distinct
namespace plus one read per shard. A shard it cannot parse marks the view `partial` instead of being
dropped.
**Making them public is Master's switch, per Worker, at deploy:**
`npx wrangler deploy --var STATS_PUBLIC:1` (and the same with `--config wrangler.x402.jsonc`). Only
the exact value `1` publishes. A `--var` lasts only until the next plain deploy, which makes the
counts private again. With it on, today's public behaviour returns:
`curl -s https://cryptoport-mcp.cryptoport-port.workers.dev/stats?days=14` (1..31, default 14) and
`/healthz` → `.demand` (last 7 days). The public payload is B-2-gated like every other response.
Days before `counting_since` were never measured: they are **omitted, not zero**. Arrays of
`{name, …}` only, never maps keyed by tool, so the key-walking B-2 gate sees declared keys only; a
malformed or foreign KV value contributes nothing (tested).
**Kill switch, no code change:** `npx wrangler deploy --var STATS_ENABLED:0` (same for the paid
config). The Node server (`npm start`) has no KV and counts nothing; with `STATS_PUBLIC=1` its
`/stats` says so.
**Cloudflare-side numbers (checked 2026-09-23, read-only).** The box's wrangler OAuth grant has no
analytics scope (`wrangler whoami` lists no analytics or observability read), and wrangler has no
analytics-read command, so request counts from Cloudflare's GraphQL Analytics API need a scoped
**API token with Account Analytics: Read** — Master's gate; not attempted. That API would give
per-Worker request counts by status, but never the MCP tool name (it lives in the POST body),
which is why this counter exists at all.
### Deploy status (2026-09-23) — BOTH Workers need a deploy for the counter
| Worker | Live build | Needs |
|---|---|---|
| `cryptoport-mcp` (free) | 2026-09-08 03:00 UTC | `npx wrangler deploy` — for the counter |
| `cryptoport-x402` (paid, testnet) | 2026-09-07 03:14 UTC | `bash ~/x402.sh go` — for the 9/07 fixes **and** the counter |
Commit the changed source first: `scripts/deploy-x402.sh` refuses a dirty `src/`, `wrangler*.jsonc`
or `package*.json`. Both `/stats` URLs answer 404 **before and after** the deploy (private default), so
a curl cannot tell the builds apart. Verify with `node scripts/stats-read.mjs` instead: it reads
`counting_since=never` until the first outside call reaches a Worker running the counter build.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues