Server data from the Official MCP Registry
Pay-per-call tools for autonomous agents, settled in USDC on Base via x402.
About
Pay-per-call tools for autonomous agents, settled in USDC on Base via x402.
Remote endpoints: streamable-http: https://ai.oliverkiss.com/mcp
Security Report
Remote MCP endpoint verified (97ms response). Server: agentic-endpoints. 12 tools available. 2 trust signals: valid MCP protocol, registry import. No security issues detected.
12 tools verified · Open access · 1 issue found
Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.
Remote servers are capped at 8.0 because source code is not available for review. The score reflects endpoint verification only.
Permissions Required
This plugin requests these system permissions. Most are normal for its category.
How to Connect
Remote Plugin
No local installation needed. Your AI client connects to the remote endpoint directly.
Add this to your MCP configuration to connect:
{
"mcpServers": {
"com-oliverkiss-agentic-endpoints": {
"url": "https://ai.oliverkiss.com/mcp"
}
}
}Documentation
View on GitHubFrom the project's GitHub README.
agentic-endpoints
x402-powered micro-SaaS utilities for autonomous AI agents. Pay-per-call with USDC micropayments on Base — no API keys, no accounts, no subscriptions.
Live at https://ai.oliverkiss.com
Endpoints
| Route | Method | Price | Description |
|---|---|---|---|
/once-key | POST | $0.001 | Claim an action exactly once, and replay its recorded result |
/once-key/complete | POST | Free | Record the outcome of a claimed action |
/once-key/release | POST | Free | Surrender a claim whose work failed |
/scrape | POST | $0.005 | Web scraping and text extraction |
/pdf-parse | POST | $0.01 | PDF text extraction from a URL |
/compress | POST | $0.005 | Token compression / context reduction for LLMs |
/x402/verify | POST | $0.003 | Check a stranger's x402 endpoint before paying it |
/meetings/import | POST | $0.004 | Import a meeting transcript, private or searchable |
/meetings/search | POST | $0.006 | Full-text search across your meetings |
/meetings/summarize | POST | $0.030 | Answer a question from your meetings, with citations |
/meetings/get | POST | $0.002 | Read one meeting in full |
/meetings/list | POST | $0.001 | List meetings (metadata only) |
/meetings/delete | POST | $0.001 | Delete a meeting and its index entry |
/vault/store | POST | $0.02 | Store a client-encrypted item |
/vault/retrieve | POST | $0.02 | Retrieve a client-encrypted item |
/vault/delete | POST | $0.005 | Delete an item |
/vault/exists | POST | $0.001 | Check whether an item exists |
/vault/list | POST | $0.001 | List the keys in a namespace (metadata only) |
/vault/rotate-token | POST | Free | Replace a namespace token that may have leaked |
/credits/buy | POST | $5.00 | Buy $6.00 of prepaid credit (20% bonus) |
/credits/buy-25 | POST | $25.00 | Buy $32.50 of prepaid credit (30% bonus) |
/credits/balance | POST | Free | Check a credit balance |
/revenue | GET | Free | On-chain USDC received, read from Base |
/mcp | POST | Free to list | Remote MCP server; each tool costs its route's price |
/ | GET | Free | Service discovery (JSON) or landing page (HTML) |
/health | GET | Free | Health check |
/status | GET | Free | Uptime, error rate and latency, derived from recorded behaviour |
/stats | GET | Free | Demand funnel: challenged, paid, free, by route |
/llms.txt | GET | Free | Prose description for a model given only a URL |
/openapi.json | GET | Free | OpenAPI 3.1 description |
GET / content-negotiates: send Accept: application/json for the machine-readable
endpoint catalogue, anything else gets the HTML landing page.
How It Works
- An agent requests a paid endpoint
- The server replies
HTTP 402 Payment Requiredwith the price, network and receiving address - The agent signs a USDC transfer on Base and retries with an
X-PAYMENTheader - The facilitator verifies and settles the payment, then the handler runs
Try it — this returns a real 402 challenge, not an error:
curl -i -X POST https://ai.oliverkiss.com/once-key \
-H 'Content-Type: application/json' \
-d '{"namespace":"demo","action_key":"abc123"}'
MCP Server
Every paid endpoint is also exposed as an MCP tool over Streamable HTTP at
https://ai.oliverkiss.com/mcp, implementing revision 2026-07-28 (stateless:
no initialize handshake, no session header) with a fallback for clients still
sending the 2025-06-18 handshake.
{
"mcpServers": {
"agentic-endpoints": { "type": "http", "url": "https://ai.oliverkiss.com/mcp" }
}
}
tools/list is free so clients can discover the catalogue. tools/call
re-enters the corresponding paid route in-process, so it passes the same x402
gate, body cap and validation as a direct HTTP call. Without a valid
X-PAYMENT header the tool returns isError: true and a machine-readable
payment demand (price, payTo, asset, network) rather than performing work.
Two Ways To Pay
Per-call x402 caps revenue at whatever a buyer will tolerate signing: $1,000 at $0.005 a call is 200,000 signatures. Prepaid credits sell the same work once, in an amount worth the transaction, and let callers whose wallets cannot sign per request use the service at all.
Both paths run side by side and neither is privileged:
# Per call, unchanged
curl -X POST https://ai.oliverkiss.com/compress -H "X-PAYMENT: ..." -d '{"text":"..."}'
# Or prepay once, then no signatures
curl -X POST https://ai.oliverkiss.com/credits/buy -H "X-PAYMENT: ..." # -> credit_token
curl -X POST https://ai.oliverkiss.com/compress \
-H "X-Credit-Token: ae_..." -d '{"text":"..."}'
Omitting X-Credit-Token produces exactly the 402 challenge it always did, so
the Bazaar listing and every existing integration are unaffected.
Credits are integer micro-dollars, never floats: $0.001 has no exact binary representation, and a ledger that drifts is worse than no ledger. Each account is its own Durable Object addressed by the hash of its token, so the balance check and its debit are atomic and one account cannot queue behind another. Calls are debited before the work and refunded if it 5xxs, because an outage must not bill a customer for nothing.
The token is shown once and is not recoverable — only its hash is stored.
Discovery
An endpoint nobody can find earns nothing, so the service is registered wherever agents actually look. Every catalog below was chosen because it verifies ownership by domain or wallet rather than by a financial account — the Coinbase CDP Bazaar is skipped for exactly that reason.
| Catalog | Status | How |
|---|---|---|
| PayAI Bazaar | Not listed on mainnet — mechanism proven on testnet | Listing requires a payment that settles, once per route; reaching /verify does nothing. Confirmed on Base Sepolia: all 9 routes appeared in /discovery/resources within seconds of paying. X402_TEST_PRIVATE_KEY=0x... node scripts/trigger-indexing.mjs costs $0.068 for the whole catalogue |
| x402-list.com | Submitted, pending review | POST /api/v1/submit; free because the service is on a custom domain |
| Official MCP Registry | Published — com.oliverkiss/agentic-endpoints, status active | ./scripts/publish-registry.sh. Ownership proven by an apex TXT record and an ed25519-signed timestamp, so no financial account is involved |
| npm | Published — agentic-endpoints | cd sdk && npm publish. Counts as discovery, not just convenience: npm is crawled by every AI coding assistant, so the client is findable by the same models that would use the service |
| Smithery | Published — kiss-olie/agentic-endpoints, quality 98/100 | npx -y @smithery/cli@latest mcp publish "https://ai.oliverkiss.com/mcp" -n kiss-olie/agentic-endpoints — no auth needed, and it updates in place. The scan finds all 17 tools (re-publish after adding one, or it keeps reporting the old count). Note the namespace is kiss-olie, not the GitHub handle. Their registry API and the markdown view served to non-browser clients are both badly stale (still report 12 tools and the old favicon); only the browser-rendered page is current, so verify there |
| Glama | Not listed; claim file pre-placed | glama.json at the repo root (maintainers: ["olie-kiss"], schema glama.ai/mcp/schemas/server.json) claims the listing automatically if their GitHub crawler indexes us. The separate /.well-known/glama.json is a different schema (connector.json, an opaque glama_claim_ token) for remote connectors and needs a listing to exist first |
Aggregators such as PulseMCP ingest from the official registry, so publishing there covers several directories at once. The repository is public, so directories that crawl source repos can now see it too.
Machine-readable descriptions are generated from the same pricing table that
gates payment, so they cannot drift from what is actually charged: /llms.txt
for a model handed a bare URL, /openapi.json for tooling, plus /robots.txt
and /sitemap.xml. Tests assert the prices agree across all of them.
Revenue Monitoring
The service could demand payment for months with no way to tell whether a
payment ever arrived — including the failure mode where payments verify but
never settle. A cron trigger sweeps Base every 5 minutes for USDC Transfer
logs into the receiving address and folds them into a running ledger in KV.
Revenue is read from the chain, not from our own logs or the facilitator's
word, so it cannot be inflated by a bug on either side. GET /revenue publishes
the ledger for free — it costs nothing and gives a prospective caller evidence
the service actually transacts.
Set ALERT_WEBHOOK_URL to a Discord webhook to be notified when money lands:
npx wrangler secret put ALERT_WEBHOOK_URL
The first scan starts the watermark at the current chain head rather than genesis; scanning millions of blocks through a public RPC node would fail repeatedly and never establish a watermark at all. The watermark advances only on a successful scan, so a transient RPC failure is retried on the next tick with nothing missed.
Buyer signals
Revenue monitoring only sees money that arrived. The service takes thousands of requests a day and, so far, no revenue — and reviewing the callers, almost all of it is liveness probes, trust scanners and directory crawlers, which are indistinguishable from a customer in an access log. The gap that leaves is a genuine buyer being refused for a fixable reason and leaving without ever appearing as a distinguishable line.
src/lib/tripwire.ts emits one structured line per notable request, and the
same signals are persisted (see below), so the durable answer is:
curl -s https://ai.oliverkiss.com/stats | jq .buyer_signals
To watch them arrive live, or to search the raw log lines:
npx wrangler tail --format json | grep BUYER_SIGNAL
Searching Workers Logs after the fact needs a full-text needle, not a
$metadata.message filter: the line is a JSON blob, and the message filter
does not match inside it — it silently returns zero rather than erroring, which
reads exactly like "no buyer has ever appeared".
| Signal | Meaning | Confidence |
|---|---|---|
payment_attempt | Carried a payment authorization | high |
credit_use | An existing customer spending prepaid credit | high |
prospect_402 | An unrecognised caller hit a priced route | low |
It keys on behaviour, not identity. A monitor never carries a payment
authorization, so anything that does is trying to buy — and that holds even
when the User-Agent claims to be a bot. User-Agent only suppresses known
noise and grants nothing, because it is forged for free.
Note that the SDK inherits Node's User-Agent, which is exactly what an
anonymous script sends, so no string positively identifies a customer.
Anything unrecognised stays unclassified rather than being written off; the
tests pin this, since misfiling it would suppress the one signal worth having.
prospect_402 is a guess and is marked low confidence for that reason — it
should never be read as a sale.
The check runs ahead of the credit and x402 gates, both of which answer without reaching application code, because a refusal is precisely the event worth recording. No header values are logged and the caller IP is dropped in favour of the country Cloudflare already derived.
Signals are also persisted in the stats Durable Object and served on /stats,
because a log line is only readable while something is tailing it — a first
customer arriving overnight would otherwise leave a line nobody read, which
would then age out. The event ring is bounded by count rather than age: the
first payment attempt could be the only one for months.
Note that buyer_signals.payment_attempt starts at 1 from a deployment
verification probe on 2026-09-09 (ua: persistence-verify/1.0). It could not
be removed without exposing a mutation endpoint. The first genuine buyer is
entry two.
Published SLOs
An agent choosing between two paid services has no way to tell which one works.
GET /status is free and answers that from recorded behaviour, not a promise:
{
"uptime_24h": "100.00%",
"uptime_window": "0.5h",
"cron_ticks_24h": 6,
"requests_48h": 88,
"error_rate_48h": "0.00%",
"unknown_path_requests_48h": 10,
"latency_ms": { "p50_at_most": 25, "p95_at_most": 500, "p99_at_most": 500 }
}
Getting the number honest mattered more than getting it published. It first
shipped reporting an 11.39% error rate; every one of those was a 404 on a path
that never existed — my own probes and passing crawlers — plus tokens that were
correctly rejected. An agent reading that would have taken its money elsewhere
and been right to. So client errors (4xx) are counted separately from failures
(5xx), and requests to unknown paths are excluded from the rate entirely and
surfaced as a raw count instead. Otherwise any stranger could degrade our
published reliability just by scanning for /wp-admin.
Latency comes from histogram buckets, so the figures are reported as
p95_at_most — a bound, which is what a bucket can honestly support, rather
than a precise percentile it cannot. Uptime credits only the window actually
observed, so day one does not claim 24 hours from an hour of heartbeats. The
heartbeat is written before the revenue scan, so an outage at a public RPC
node is not reported as ours.
GET /stats publishes the demand funnel — challenged, paid, free, per route —
which is the only thing that distinguishes "nobody has found us" from "agents
arrive and decline to pay".
Client SDK
sdk/ is a dependency-free TypeScript client. It deliberately does
not sign payments — it takes a credit token, or your own x402-aware
fetch, so it never needs a private key.
npm install agentic-endpoints
Its reason to exist is exactlyOnce, which collapses the claim/complete/release
protocol into one call: it handles all four claim outcomes, records the result
so later callers can replay it, releases the claim if your work throws, and
rethrows your error untouched.
Stack
- Runtime: Cloudflare Workers + Durable Objects
- Payments: x402 protocol (USDC on Base mainnet,
exactscheme) - State: Durable Object SQLite (OnceKey and Vault)
- Framework: Hono
Setup
Prerequisites
- Node.js 20+
- Cloudflare account (Workers Paid plan — $5/mo, required for Durable Objects)
- A wallet address on Base to receive USDC
- Wrangler CLI (use
npx wranglerif not installed globally)
Install
npm install
Configure Secrets
# Required — your receiving wallet address (Base USDC)
npx wrangler secret put X402_PAY_TO
# Required — HMAC key for signing JSON receipts
npx wrangler secret put RECEIPT_SECRET
Do not set
FACILITATOR_URLunless you mean to override the default. It must be a valid URL. If it is set to anything else, every paid endpoint returns 500 instead of a 402, and the failure is only visible innpx wrangler tail— this silently broke all payments once already.
The default facilitator is https://facilitator.xpay.sh: free, no signup, and it
supports Base mainnet.
Develop and deploy
npm run dev
npm run deploy
# Tests run against the real Workers runtime via workers-pool.
# The Durable Object tests call the objects directly, since every paid
# route answers 402 without a real on-chain payment.
npm test
npm run typecheck
# Re-announce every route to the PayAI Bazaar. This SPENDS REAL USDC: a route
# is catalogued only once a payment for it settles. It refuses to start unless
# the wallet covers the whole run.
export X402_TEST_PRIVATE_KEY=0x...
node scripts/trigger-indexing.mjs
# Make one real paid call. Requires a THROWAWAY wallet holding a little USDC
# on Base; the key is read from the environment and never written anywhere.
export X402_TEST_PRIVATE_KEY=0x...
node scripts/paid-test.mjs /compress
Proving settlement without spending
Settlement is the one step that cannot be tested by inspection, and on
mainnet every attempt costs real USDC. [env.testnet] deploys the same code
to a workers.dev URL priced in Base Sepolia USDC, which
faucet.circle.com gives away with no account.
npx wrangler deploy --env testnet
X402_TEST_PRIVATE_KEY=0x... node scripts/paid-test.mjs /once-key \
https://agentic-endpoints-testnet.<subdomain>.workers.dev --testnet
Only the exact string eip155:84532 selects Sepolia; anything unrecognised
falls back to mainnet. That asymmetry is deliberate — a Worker that wrongly
demanded testnet tokens would hand out real work for money anyone can mint.
Announcing to the Bazaar
X402_TEST_PRIVATE_KEY=0x... node scripts/trigger-indexing.mjs
Pays for each route once, which is what puts it in the catalogue. $0.082 for
all 14 utility routes; credit packs are excluded unless you pass
--include-credits. The script checks the payer's USDC balance against that
total first and refuses to start if it falls short, because a wallet that runs
dry midway leaves the catalogue half-populated with no way to tell which
routes made it.
API Examples
OnceKey (exactly-once execution)
A claim on its own is only half an idempotency key. The agent that loses the race needs to know what happened, or it has to either block forever or repeat the side effect anyway — which is the failure this endpoint exists to prevent. So the lifecycle is three calls, and only the first one costs money.
POST /once-key
{
"namespace": "payment-webhooks",
"action_key": "stripe_evt_abc123",
"payload_sha256": "e3b0c44298fc...",
"ttl": 86400,
"lease_ttl": 300
}
status | Meaning |
|---|---|
claimed | You won. Do the work, then call /once-key/complete |
duplicate | Already done. result holds the original outcome — use it. has_result is false if the original caller recorded no result, in which case result is null because there is nothing to replay |
held | Claimed by someone who set no lease and never finished. There is no result and may never be one. Do not do the work and do not treat it as done; locked until expires_at |
in_progress | Another caller holds a live lease. Wait retry_after; do not do the work |
conflict | Same key, different payload hash. Your key derivation is wrong; never retry |
POST /once-key/complete // free
{ "namespace": "...", "action_key": "...", "namespace_token": "...",
"result": { "charge_id": "ch_abc" } }
Every later claim of that key returns duplicate with that result.
duplicate always means completed. A claim that was started but never
completed reports held instead, precisely so a caller cannot mistake work
that is still in flight — or that died half way through — for work that
succeeded. The SDK raises HeldError rather than returning a result of
undefined.
If the work fails, POST /once-key/release (free) frees the key immediately.
lease_ttl is opt-in, deliberately. Without it a claim is held for its
full ttl and nothing can ever run your side effect twice. With it, a
claimant that crashes is presumed dead once the lease lapses and the next
caller takes over with recovered: true. Leases on by default would have
made every key claimed by the original claim-only API silently reclaimable —
a duplicated charge is a far worse failure than a key that needs a retry
under a fresh name.
The agentic-endpoints npm package wraps all of this in one call:
const { outcome, result } = await client.exactlyOnce(
{ namespace: "billing", actionKey: `charge:${order.id}`, leaseTtl: 300 },
async () => stripe.charges.create({ amount: order.total }),
);
Web scraper
POST /scrape
{ "url": "https://example.com/article", "format": "text" }
PDF parser
POST /pdf-parse
{ "url": "https://example.com/document.pdf", "pages": [1, 2] }
Inflates FlateDecode content streams, expands PDF 1.5+ object streams, and maps
character codes through each font's /ToUnicode CMap, so subset and composite
fonts come back as real text rather than glyph indices. Encrypted PDFs and
image-only scans return 422 rather than filler content, so callers are not
billed for a result that is known to be useless.
Token compressor
POST /compress
{ "text": "Your very long text here...", "target_tokens": 500, "strategy": "extractive" }
Meeting memory (agent-queryable transcripts)
Meeting notetakers keep transcripts inside their own app, where the only reader is a human scrolling a sidebar. This puts them somewhere your agents can ask questions of them.
You choose, per meeting, whether this service can read it. The choice is required, and the wrong field for the mode is refused rather than guessed.
visibility | You send | Stored as | Searchable | We can read it |
|---|---|---|---|---|
private (default) | ciphertext | opaque bytes | no | no |
queryable | transcript | plaintext + FTS index | yes | yes |
Sending plaintext as private is an error rather than a quiet indexing, and
sending ciphertext as queryable is an error rather than a meeting that can
never match a search. Both refusals exist because the failure they prevent is
silent and only discovered long after it matters.
# First import claims the namespace and returns a one-time token.
curl -X POST https://ai.oliverkiss.com/meetings/import \
-H "Content-Type: application/json" \
-d '{
"namespace": "my-meetings-4f9c2b1e8d7a",
"title": "Pricing review",
"occurred_at": "2026-09-01T15:00:00.000Z",
"source": "webvtt",
"visibility": "queryable",
"participants": ["Alice", "Bob"],
"transcript": "Alice: we agreed to ship the redesign before the audit."
}'
# Then ask a question of every meeting at once.
curl -X POST https://ai.oliverkiss.com/meetings/search \
-H "Content-Type: application/json" \
-d '{
"namespace": "my-meetings-4f9c2b1e8d7a",
"namespace_token": "<from the first import>",
"query": "pricing"
}'
Search returns ranked excerpts plus searched_meetings and
private_meetings_skipped. Read them. If searched_meetings is 0, an empty
result means nothing was searched, not that the topic was never discussed —
and an agent that conflates those will confidently tell a user something never
happened.
Via MCP the same thing is meetings_search, meetings_import, meetings_get
and meetings_list.
Asking a question instead of reading excerpts
/meetings/search hands back ranked excerpts and leaves the reasoning to you.
/meetings/summarize takes a plain-language question, retrieves the relevant
transcripts, and answers from them with a citation per claim:
curl -X POST https://ai.oliverkiss.com/meetings/summarize \
-H "Content-Type: application/json" \
-d '{
"namespace": "my-meetings-4f9c2b1e8d7a",
"namespace_token": "<from the first import>",
"question": "what did we decide about pricing?"
}'
{
"status": "ok",
"answer": "You agreed to defer the pricing change until Q3 [6f1c3b90-…].",
"consulted": [
{ "meeting_id": "6f1c3b90-…", "title": "Pricing review", "chars_used": 4820, "truncated": false }
],
"terms": ["decide", "pricing"],
"searched_meetings": 12,
"private_meetings_skipped": 3
}
The question is not FTS5 syntax — the terms are derived from it and returned
as terms, so a bad answer can be traced to bad retrieval rather than
guessed at. consulted is what the answer was actually built from; a
truncated entry means only part of that transcript was read, so its silence
on a point is not evidence.
The negative cases matter more than the positive one. A summarizer's dangerous failure is not a wrong answer but a confident one assembled from nothing, because the caller cannot tell the difference afterwards. So:
| Situation | What happens |
|---|---|
| No meeting matched | no_matches, the model is never called |
| Every meeting is private | no_matches with searched_meetings: 0 and an explicit warning not to read it as "never discussed" |
| Question was all stopwords | unusable_question, no answer |
| Model unreachable | 503 with answer: null — plus the meetings it already found, so you can read them with meetings_get rather than paying again |
Private meetings can never contribute to an answer; private_meetings_skipped
says how many were excluded. Answers are generated by
@cf/meta/llama-4-scout-17b-16e-instruct on Workers AI, pinned rather than
floating — a silently swapped model would change every answer this endpoint
has ever given.
Via MCP this is meetings_summarize.
Importing a raw export
transcript accepts a WebVTT or SRT file exactly as Zoom, Teams, Meet or a
notetaker emits it — header, cue numbers, timestamps and <v Speaker> spans
included. You do not have to write a parser first.
The format is detected from the content, not from source; that field is a
hint and is ignored when it disagrees with the file. Timestamps and cue
numbering are stripped before indexing, and consecutive cues from the same
speaker are merged, because exports split on timing rather than grammar:
00:00:04.120 --> 00:00:07.880 <v Alice>we agreed to ship the redesign
00:00:07.900 --> 00:00:09.400 <v Alice>before the security audit
Indexed cue-by-cue, a search for "redesign before the security audit" matches
nothing — the phrase exists in the meeting but not in any one cue. Merged, it
matches. Speakers found in the file are added to participants alongside any
you declared, so attendees who never spoke are not lost.
A parsed import reports what happened:
"parsed": { "format": "webvtt", "cues": 3, "speakers": ["Alice", "Bob"], "indexed_bytes": 71 }
The absence of that field means the text was stored verbatim. Plain text is
unchanged, and private meetings are never parsed — the body is ciphertext
this service cannot read.
Checking an endpoint before paying it
An agent that pays automatically cannot notice that the money started going somewhere else. Every response still returns 200, the price still looks right, and by the time a human looks it has happened a thousand times.
curl -X POST https://ai.oliverkiss.com/x402/verify \
-H "X-PAYMENT: <signed payload>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/api/thing",
"expect": { "pay_to": "0xabc...", "max_price_usd": 0.01 }
}'
It fetches the endpoint's live payment challenge and compares it against every observation made by every previous caller. That comparison is the part an agent cannot do for itself — it can remember what it saw, not what everyone else saw.
{
"status": "ok",
"charges": {
"pay_to": "0xabc...",
"amount": "10000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"network": "eip155:8453",
"price_usd": "0.01"
},
"first_seen": "2025-01-01T00:00:00.000Z",
"times_seen": 14,
"first_observation": false,
"drift": [
{
"field": "pay_to",
"severity": "critical",
"from": "0xabc...",
"to": "0xdef...",
"note": "The receiving address changed. Payments now go somewhere other than where earlier callers sent them."
}
]
}
A pay_to, network or asset that this endpoint has never offered before
is critical — those determine where the money goes. A price change is a
warning at worst.
The comparison is over the whole accepts list, not just the first entry.
A real client pays through the option matching a chain and token it holds,
which need not be index 0, so an endpoint could otherwise list an honest
option first and an attacker's payee second and pass a check that only reads
the first. Reordering the list is reported as a warning, not a critical,
because the same destinations remain on offer.
Addresses are compared case-insensitively. EIP-55 checksumming is presentation, not meaning, and reporting a re-cased address as a changed payee would fire the most severe alarm this tool has at an endpoint where nothing moved.
History is kept per method and URL, not per URL. method is caller-supplied,
and plenty of x402 services price GET and POST differently — a read tier and
a compute tier. Sharing one history between them would let anyone spend $0.003
to make an honest endpoint appear to the next caller to have swapped its
payee, and alternating the two would flap that baseline indefinitely. The URL
is canonicalised first (host lowercased, default port dropped, fragment
removed) so that different spellings of one endpoint still share one history
rather than fragmenting it.
What it does not tell you
| Reading | What it actually means |
|---|---|
"status": "ok" | A challenge was read and recorded. Not that the operator will deliver anything. |
"drift": [] with "first_observation": true | There is no history to compare against. Absence of drift is not evidence of stability. |
"drift": [] with "prior_criticals": 3 | This endpoint has changed where money goes before. drift compares against the last observation only, so once a swap becomes the baseline it stops appearing there. Read prior_criticals and last_critical before concluding anything from an empty drift. |
"status": "unreachable" | Neither evidence of fraud nor of health. Nothing is recorded, so one timeout cannot manufacture a payee-change alarm later. drift is null rather than [], because no comparison was made. |
"price_usd": null | The token's decimals are unknown here, so the amount was not converted. Not a claim that it is small. |
"status": "not_x402" | No challenge was found. The endpoint may be free, may want a different method, or may not use x402 at all. |
"status": "refused" | The URL was never fetched. It is checked by the same outbound guard /scrape and /pdf-parse use, which resolves the hostname over DNS-over-HTTPS and rejects private answers — so a public name pointed at 127.0.0.1 is refused too. |
Network identifiers are normalised before comparison, so an endpoint moving
from x402 v1's "base" to v2's "eip155:8453" is correctly read as the same
chain rather than as a critical chain change.
Failures are reported coarsely (timeout or unreachable) and a redirect
target is never echoed back. Reflecting the real error or the Location header
would turn a $0.003 call into a network-probe oracle for any host a caller
names.
A critical is recorded permanently, not just reported to whoever happened to
call first. Comparing against the previous observation alone would mean an
attacker's challenge becomes the baseline after one call, and every caller
afterwards sees an empty drift beside a growing times_seen — a record that
reads as stability because the change was absorbed, not because nothing
happened. prior_criticals and last_critical do not expire when a later look
is clean, and the advice says so first.
The word "safe" never appears in a response, deliberately. This reports what an endpoint declares about itself; it cannot certify an operator.
Vault
Storage is free; retrieval is paid. The server only ever sees ciphertext — encrypt client-side before calling.
Namespaces are caller-chosen strings, so they are claimed on first write. That
first store returns a namespace_token once; every later operation on the
namespace must present it.
POST /vault/store
{ "namespace": "my-app-4f9c2b1e8d7a", "key": "secret-1", "ciphertext": "base64...", "ttl": 86400 }
// First write only — save this, it is not shown again
{ "status": "stored", "namespace_token": "30ab4b26..." }
POST /vault/retrieve
{ "namespace": "my-app-4f9c2b1e8d7a", "key": "secret-1", "namespace_token": "30ab4b26..." }
Writes are last-write-wins unless you say otherwise, so two agents rotating
the same secret would clobber each other silently. Pass if_match with the
item's current updated_at for a compare-and-swap, or if_absent to create
only; either answers status: "precondition_failed" instead of overwriting.
POST /vault/list ($0.001) returns the keys and their versions but never any
ciphertext — that is what the $0.02 retrieve is for.
Rotate a token you think has leaked, with POST /vault/rotate-token. It
is free: putting a price on the correct response to a suspected leak is how
you get callers who never rotate. It requires the current token, and there
is no recovery if that is lost — any path that could restore access without
it would be a second way in, and would serve an attacker just as readily as
the owner. Lose it and the namespace is gone by design; there is no account
to reset it against.
Limits
| Limit | Value |
|---|---|
Free requests (no X-PAYMENT) | 60/min per IP, per Cloudflare location |
| Vault writes | 20/min per IP |
| Paid requests | 600/min |
| Request body | 2 MiB |
| Vault item | 256 KiB ciphertext |
| Vault namespace | 1,000 items / 25 MiB |
Paid requests are throttled far more loosely than anonymous ones, because a caller who is paying per call already has a spend ceiling. They are not unlimited.
Concurrent paid calls
Paid calls issued concurrently from the same wallet are refused a
noticeable fraction of the time — measured at 2 in 10 up to 8 in 15 on Base
Sepolia, independent of our rate limits, and reproducible with as few as 5 in
flight. The refusal is an ordinary 402, and it originates at the facilitator,
which will not verify overlapping authorizations from one payer.
Nothing is charged for a refused call. Measured directly: 10 concurrent calls at $0.005, 8 settled, 2 refused, and the payer's balance moved by exactly $0.040. Any status at or above 400 cancels x402 settlement, and that holds here.
So a 402 on a paid request means the payment did not happen and it is safe to
retry with a fresh signature. If you need throughput, issue paid calls
sequentially, or retry on 402 with a short backoff. Do not treat a 402 as a
charge you need to reconcile.
Since 0.4.0 the SDK does this for you: post() replays a 402 twice by
default, with jittered backoff, and maxPaymentRetries tunes it. Jitter is not
decoration — the colliding requests belong to a single payer, so a fixed delay
would realign them on the next attempt. Retries only happen when you supplied
an x402-aware fetch; the SDK never signs payments itself, so without one a
402 is terminal and is surfaced immediately rather than stalling.
Security Notes
- URL-taking endpoints are SSRF-guarded (
src/lib/url-guard.ts): scheme allowlist, private/reserved IPv4 and IPv6 ranges blocked, hostnames resolved over DNS-over-HTTPS and private answers rejected, every redirect hop re-validated, and response bodies bounded. It fails closed. It does not defeat DNS rebinding, and does not claim to: the DoH probe and the fetch are two independent resolutions, so a hostile low-TTL nameserver can answer them differently. Pinning the fetch to the vetted address is not expressible on Workers —cf.resolveOverrideonly accepts hostnames inside your own zone, and an IP literal breaks TLS SNI. What bounds the risk is the egress path: Workers reach the internet through Cloudflare's network, which has no route to RFC1918 or loopback and exposes no metadata endpoint. Do not place anything sensitive where this Worker's egress can reach it on the assumption that this guard stops it. - Vault and OnceKey namespaces are ownership-gated. The first request to a
namespace is issued a one-time
namespace_token; tokens are stored only as SHA-256 hashes and compared in constant time. - New namespaces must be unguessable (16+ characters, mixed character
classes). Ownership is first-writer-wins over a global, account-less string
and there is deliberately no recovery path, so a short name like
invoicesorbillingcould be claimed by anyone for $0.001 and would lock out the rightful owner permanently. Making real namespaces unguessable means there is nothing worth squatting. Usemyapp-<uuid>. Names claimed before this rule keep working. - The free lifecycle endpoints do not reveal whether a namespace exists.
/once-key/completeand/once-key/releasereturn an identical 404 whether the namespace was never claimed or your token is wrong, because a free existence oracle is the reconnaissance step before squatting. The paid/once-keyand/vault/*routes answer200withstatus: "forbidden"instead of403, so that each probe actually settles a payment — a 4xx would cancel settlement and leave the payment header replayable, making the oracle free after all. Branch onstatus, not on the HTTP code. namespace_tokenis a bearer credential with no recovery path. Anyone holding it is the owner. Worse than a normal leak:/vault/rotate-tokenis free and needs only the current token, so whoever steals it can rotate first and lock you out irreversibly. OnceKey has no rotation at all, so a leaked OnceKey token is permanent. There are no accounts, no email, and no support channel that can restore access — treat these tokens like a private key, and store them before you make the call that returns one.- The vault cannot read your values, but it does see their names. No key
held here can decrypt anything, and plaintext is never received. But the
item key, the namespace, the
alglabel and the size are all stored in the clear, so the service can tell which named secrets you hold and how large they are.algis an advisory label: nothing here can verify that what you sent was in fact encrypted. Use high-entropy namespace names — ownership is first-writer-wins, so a guessable namespace can be squatted (now enforced; see above). - Paid routes answer completed work with 200 and a
statusfield, never a 4xx. The x402 middleware cancels settlement above 399, so a 4xx returned after the work is done gives the answer away free and leaves the payment header replayable. - Receipts are HMAC-signed with
RECEIPT_SECRET. Note that only/once-keyand the vault endpoints return areceipt— the stateless utilities (/pdf-parse,/scrape,/compress) do not. - The wallet is the trust anchor. Nothing in this codebase protects the seed
phrase behind
X402_PAY_TO. If it leaks, the money is gone.
Money Flow
Agent pays USDC on Base
→ lands directly in your wallet (X402_PAY_TO)
→ transfer to an exchange that supports Base USDC
→ withdraw to your bank
Canadian-friendly off-ramps: Kraken, Newton, Shakepay, Coinbase. US-only services such as Mercury and Sphere Pay are not an option.
Known Gaps
Documentation truncated — see the full README on GitHub.
Reviews
No reviews yet
Be the first to review this server!
More Developer Tools MCP Servers
Paperclip
Freeby Paperclipai · Developer Tools
Trending hip-hop artist momentum scores across four cultural dimensions.
Git
Freeby Modelcontextprotocol · Developer Tools
Read, search, and manipulate Git repositories programmatically
Toleno
Freeby Toleno · Developer Tools
Toleno Network MCP Server — Manage your Toleno mining account with Claude AI using natural language.
mcp-creator-python
Freeby mcp-marketplace · Developer Tools
Create, build, and publish Python MCP servers to PyPI — conversationally.
MCP Marketplace
Freeby mcp-marketplace · Developer Tools
Search and install MCP servers from inside your AI client.
MarkItDown
Freeby Microsoft · Content & Media
Convert files (PDF, Word, Excel, images, audio) to Markdown for LLM consumption
